@ -0,0 +1,154 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { AnalyticsProviderInput } from '../models/analytics.ts'
|
||||
|
||||
/**
|
||||
* Analytics API Routes
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* GET SITE ANALYTICS CONFIGURATION
|
||||
*/
|
||||
app.get<{ Params: { siteId: string } }>(
|
||||
'/sites/:siteId/analytics',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Get the analytics configuration of a site',
|
||||
description:
|
||||
'One entry per analytics module installed in `modules/analytics`, whether or not it has ever been enabled, each with the values this site has configured for it. Nothing is masked: every value here ends up in the HTML the site serves, so none of it can be a secret.',
|
||||
tags: ['Analytics'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Analytics configuration of the site',
|
||||
type: 'object',
|
||||
properties: {
|
||||
providers: {
|
||||
type: 'array',
|
||||
items: { $ref: 'AnalyticsProvider#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||||
if (!site) {
|
||||
return reply.notFound('Site does not exist.')
|
||||
}
|
||||
return { providers: WIKI.models.analytics.getSiteProviders(req.params.siteId) }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* UPDATE SITE ANALYTICS CONFIGURATION
|
||||
*/
|
||||
app.put<{
|
||||
Params: { siteId: string }
|
||||
Body: { providers?: AnalyticsProviderInput[] }
|
||||
}>(
|
||||
'/sites/:siteId/analytics',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Update the analytics configuration of a site',
|
||||
description:
|
||||
'Only the providers listed are affected, and within each of them only the props the module declares. Everything is validated before any of it is written, so a rejected request changes nothing. A saved change applies to the next document the site serves, on every instance.',
|
||||
tags: ['Analytics'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId']
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
providers: {
|
||||
type: 'array',
|
||||
items: { $ref: 'AnalyticsProviderInput#' }
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Analytics configuration updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: {
|
||||
type: 'boolean'
|
||||
},
|
||||
message: {
|
||||
type: 'string'
|
||||
},
|
||||
updated: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'How many providers were written. One already in the requested state still counts.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||||
if (!site) {
|
||||
return reply.notFound('Site does not exist.')
|
||||
}
|
||||
|
||||
// -> Validated as a whole first: the admin area saves every provider at once, and a partially
|
||||
// applied configuration is worse than a refused one
|
||||
const patches = req.body.providers ?? []
|
||||
for (const patch of patches) {
|
||||
const invalid = WIKI.models.analytics.validateProvider(patch)
|
||||
if (invalid) {
|
||||
return reply.badRequest(invalid)
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await WIKI.models.analytics.updateSiteProviders(req.params.siteId, patches)
|
||||
|
||||
/*
|
||||
Which providers were written and whether each was turned on — not the values, which are
|
||||
identifiers of accounts at a third party rather than anything about this wiki. `meta` carries
|
||||
identity and never payload.
|
||||
*/
|
||||
await audit(req, 'admin', 'updateAnalytics', {
|
||||
siteId: req.params.siteId,
|
||||
providers: patches.map((patch) => ({
|
||||
key: patch.key,
|
||||
isEnabled: patch.isEnabled,
|
||||
changedFields: Object.keys(patch.config ?? {})
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: 'Analytics configuration updated successfully.',
|
||||
updated
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -0,0 +1,76 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* ANALYTICS PROVIDER - An analytics module as configured for a site
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'AnalyticsProvider',
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Directory name under `modules/analytics`.'
|
||||
},
|
||||
title: {
|
||||
type: 'string'
|
||||
},
|
||||
description: {
|
||||
type: 'string'
|
||||
},
|
||||
website: {
|
||||
type: 'string',
|
||||
description: "The provider's own site."
|
||||
},
|
||||
icon: {
|
||||
type: 'string'
|
||||
},
|
||||
isEnabled: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether this provider contributes markup to the documents the site serves. Several providers may be on at once; each contributes its own tag.'
|
||||
},
|
||||
requires: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'The config keys that must hold a value before the provider renders anything. An enabled provider missing one of these is skipped rather than served with an empty tracking ID in it.'
|
||||
},
|
||||
props: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'The configuration fields the module declares, as the admin area renders them. Read-only: what a module needs configured is a property of the module, not of the site.'
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description:
|
||||
"The stored value of each prop, completed from the module's defaults. Never masked - every value here is rendered into a document served to the public, so a secret could not be one of them."
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* ANALYTICS PROVIDER INPUT - What a client may change about one provider
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'AnalyticsProviderInput',
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string'
|
||||
},
|
||||
isEnabled: {
|
||||
type: 'boolean'
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'Values for the props the module declares. Unknown keys are dropped and read-only props are ignored.'
|
||||
}
|
||||
},
|
||||
required: ['key']
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,408 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { htmlEscape, parseModuleProps } from '../helpers/common.ts'
|
||||
import type { ModuleProp } from '../helpers/common.ts'
|
||||
|
||||
/**
|
||||
* The two places a provider's markup can be asked to go.
|
||||
*
|
||||
* `head` is where nearly everything belongs — a tracking tag is loaded as early as possible so that
|
||||
* it sees the page load it is meant to be counting. `bodyStart` exists for the one thing that cannot
|
||||
* go in the head: Google Tag Manager's `<noscript>` fallback, which is an `<iframe>` and so has to be
|
||||
* in the body, immediately after it opens.
|
||||
*/
|
||||
const SLOTS = ['head', 'bodyStart'] as const
|
||||
|
||||
type Slot = (typeof SLOTS)[number]
|
||||
|
||||
/**
|
||||
* A placeholder in a provider's code template: `{{<context>:<prop>}}`.
|
||||
*
|
||||
* The context is how the value is written into the snippet, and it is declared in the template rather
|
||||
* than on the prop because the same value goes into different places — a Matomo server URL is a
|
||||
* JavaScript string in the tracker and an attribute in the `<noscript>` pixel beside it, and those
|
||||
* escape differently. See `resolvePlaceholder`.
|
||||
*/
|
||||
const PLACEHOLDER = /\{\{(js|attr|num|bool):([A-Za-z0-9_]+)\}\}/g
|
||||
|
||||
/**
|
||||
* What a character becomes inside a JavaScript string literal in an inline `<script>`.
|
||||
*
|
||||
* The quotes and the backslash are the obvious half — an apostrophe in a site name would otherwise
|
||||
* end the string it is in. `<`, `>` and `&` are the half that is easy to miss: the contents of a
|
||||
* `<script>` element are not parsed for entities, but the HTML parser still ends the element at
|
||||
* `</script`, and `<!--` inside one changes how the rest of it is read. Escaping the three characters
|
||||
* as `\uXXXX` keeps the value from ever meaning anything to the parser, while reading back as itself
|
||||
* in JavaScript. `\u2028` and `\u2029` are line terminators to a JavaScript parser and nothing to
|
||||
* anybody else, so an unescaped one is a syntax error nobody can see.
|
||||
*/
|
||||
const JS_ESCAPES: Record<string, string> = {
|
||||
'\\': '\\\\',
|
||||
"'": "\\'",
|
||||
'"': '\\"',
|
||||
'`': '\\`',
|
||||
'\n': '\\n',
|
||||
'\r': '\\r',
|
||||
'\t': '\\t',
|
||||
'<': '\\u003C',
|
||||
'>': '\\u003E',
|
||||
'&': '\\u0026',
|
||||
'\u2028': '\\u2028',
|
||||
'\u2029': '\\u2029'
|
||||
}
|
||||
|
||||
const JS_ESCAPE_PATTERN = /[\\'"`\n\r\t<>&\u2028\u2029]/g
|
||||
|
||||
/** An analytics module, as declared by its `definition.yml` and `code.yml`. */
|
||||
export interface AnalyticsDefinition {
|
||||
/** Directory name under `modules/analytics`, and how a site's config addresses the provider. */
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
/** The provider's own site, linked from the panel beside its configuration. */
|
||||
website: string
|
||||
icon: string
|
||||
props: Record<string, ModuleProp>
|
||||
/**
|
||||
* The props that must hold a value before this provider renders anything at all.
|
||||
*
|
||||
* A tag with an empty tracking ID in it is not a tag that collects less — it is a script that
|
||||
* reports to nothing, or to whatever account an empty ID happens to resolve to at the other end. So
|
||||
* an enabled provider missing one of these is skipped, and the admin area says which field is
|
||||
* empty rather than letting the wiki serve a broken snippet.
|
||||
*
|
||||
* A prop left out of this list may legitimately be empty, e.g. an Elastic APM environment name.
|
||||
*/
|
||||
requires: string[]
|
||||
/** The markup each slot contributes, before any value is substituted into it. */
|
||||
code: Record<Slot, string>
|
||||
}
|
||||
|
||||
/** One provider as a site has it configured, which is what the admin area edits. */
|
||||
export interface AnalyticsProvider {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
website: string
|
||||
icon: string
|
||||
isEnabled: boolean
|
||||
props: Record<string, ModuleProp>
|
||||
config: Record<string, any>
|
||||
requires: string[]
|
||||
}
|
||||
|
||||
/** What a client may change about one provider. */
|
||||
export interface AnalyticsProviderInput {
|
||||
key: string
|
||||
isEnabled?: boolean
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
/** The markup a site contributes to every document it serves. See `injectionsFor`. */
|
||||
export type AnalyticsInjections = Record<Slot, string>
|
||||
|
||||
/** A site with nothing configured, which is every site until somebody turns a provider on. */
|
||||
const NO_INJECTIONS: AnalyticsInjections = { head: '', bodyStart: '' }
|
||||
|
||||
/**
|
||||
* Analytics model
|
||||
*
|
||||
* An analytics provider is one module from `modules/analytics/<key>/` turned on for one site. The
|
||||
* module is two files: a `definition.yml` declaring what it is and what it needs configured, and a
|
||||
* `code.yml` holding the markup it contributes, with `{{context:prop}}` placeholders for the values.
|
||||
* There is no third file — unlike a storage module a provider has no code to run here, since the
|
||||
* whole of what it does happens in the reader's browser.
|
||||
*
|
||||
* **The markup is served, not injected by the app.** It goes into the document the server hands out
|
||||
* (`helpers/appShell.ts`), so it is in the HTML of every response including the one a client that
|
||||
* never runs JavaScript receives. That is deliberate and is the point: several providers verify an
|
||||
* installation by fetching the page and looking for their tag, which a script the SPA adds after boot
|
||||
* would fail — and a tag added after boot also misses the load it exists to measure.
|
||||
*
|
||||
* **Configuration lives in the site's own config blob**, under `analytics.providers`, rather than in
|
||||
* a table of its own. Every request that produces a document needs it, and the site configurations
|
||||
* are already in memory on every instance (`WIKI.sites`) and already reloaded across the cluster when
|
||||
* one changes — so an analytics tag costs no query, and a saved change applies to the next request.
|
||||
*
|
||||
* **`manage:sites` is the trust boundary**, the same as for the theme's head and body injections
|
||||
* beside which this markup lands. The escaping here is about correctness rather than privilege: a
|
||||
* value has to stay inside the string or the attribute it was written into, so that an apostrophe in
|
||||
* a service name cannot break every script on the page.
|
||||
*/
|
||||
class Analytics {
|
||||
/** Definitions read from disk, refreshed by `refreshFromDisk()`. */
|
||||
definitions: AnalyticsDefinition[] = []
|
||||
|
||||
/**
|
||||
* Load the analytics module definitions from disk.
|
||||
*
|
||||
* One directory per provider, each with both files. A directory missing either is skipped with a
|
||||
* warning rather than taking the rest down with it: a provider that cannot be read is one provider
|
||||
* nobody can turn on, where an empty list would silently stop every site's existing tags.
|
||||
*/
|
||||
async refreshFromDisk(): Promise<void> {
|
||||
const analyticsPath = path.join(WIKI.SERVERPATH, 'modules/analytics')
|
||||
const definitions: AnalyticsDefinition[] = []
|
||||
try {
|
||||
for (const dir of await fs.readdir(analyticsPath)) {
|
||||
try {
|
||||
const parsed = load(
|
||||
await fs.readFile(path.join(analyticsPath, dir, 'definition.yml'), 'utf8')
|
||||
) as Record<string, any>
|
||||
const code = load(
|
||||
await fs.readFile(path.join(analyticsPath, dir, 'code.yml'), 'utf8')
|
||||
) as Record<string, any>
|
||||
definitions.push({
|
||||
// -> The directory name is the key, as it is for every other module type
|
||||
key: dir,
|
||||
title: parsed.title ?? dir,
|
||||
description: parsed.description ?? '',
|
||||
website: parsed.website ?? '',
|
||||
icon: parsed.icon ?? '',
|
||||
// -> Props carry a display `order`, applied once here so that every consumer reads them
|
||||
// in the order the module meant them to be shown in
|
||||
props: Object.fromEntries(
|
||||
Object.entries(parseModuleProps(parsed.props ?? {})).sort(
|
||||
([, a], [, b]) => a.order - b.order
|
||||
)
|
||||
),
|
||||
requires: parsed.requires ?? [],
|
||||
code: {
|
||||
head: typeof code?.head === 'string' ? code.head.trim() : '',
|
||||
bodyStart: typeof code?.bodyStart === 'string' ? code.bodyStart.trim() : ''
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
WIKI.logger.warn(`Skipping analytics module ${dir}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
this.definitions = definitions.sort((a, b) => a.title.localeCompare(b.title))
|
||||
WIKI.logger.info(`Found ${this.definitions.length} analytics modules [ OK ]`)
|
||||
} catch (err: any) {
|
||||
this.definitions = []
|
||||
WIKI.logger.error(
|
||||
`Could not read the analytics module definitions at ${analyticsPath} [ FAILED ]`
|
||||
)
|
||||
WIKI.logger.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** A single definition, or null when nothing on disk declares that key. */
|
||||
getDefinition(key: string): AnalyticsDefinition | null {
|
||||
return this.definitions.find((d) => d.key === key) ?? null
|
||||
}
|
||||
|
||||
/** What a site has stored, keyed by module. Empty for a site that has never saved this screen. */
|
||||
storedProviders(siteId: string): Record<string, { isEnabled?: boolean; config?: any }> {
|
||||
return WIKI.sites[siteId]?.config?.analytics?.providers ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every provider installed on disk, with what this site has configured for it merged in.
|
||||
*
|
||||
* Driven by the definitions rather than by what is stored, so a provider that has never been
|
||||
* touched is listed with its defaults and one dropped from disk simply stops appearing — its stored
|
||||
* values stay in the site config, harmless and ignored, until the screen is next saved.
|
||||
*/
|
||||
getSiteProviders(siteId: string): AnalyticsProvider[] {
|
||||
const stored = this.storedProviders(siteId)
|
||||
return this.definitions.map((definition) => ({
|
||||
key: definition.key,
|
||||
title: definition.title,
|
||||
description: definition.description,
|
||||
website: definition.website,
|
||||
icon: definition.icon,
|
||||
requires: definition.requires,
|
||||
isEnabled: stored[definition.key]?.isEnabled === true,
|
||||
props: definition.props,
|
||||
config: this.buildConfig(definition.key, {}, stored[definition.key]?.config ?? {})
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge incoming config values onto the ones already stored, keeping only what the module declares.
|
||||
*
|
||||
* Unknown keys are dropped rather than refused, so a provider that loses a prop does not make the
|
||||
* screen unsaveable. Read-only props are never taken from the client — the same rule the storage
|
||||
* and authentication forms follow.
|
||||
*
|
||||
* There is no sensitive-prop handling here, and there is no `maskSensitiveProps` on the route
|
||||
* either: every value on this screen is rendered into a document served to the public, so a prop
|
||||
* that had to be kept out of a browser could not be used by a provider in the first place.
|
||||
*/
|
||||
buildConfig(
|
||||
moduleKey: string,
|
||||
incoming: Record<string, any> = {},
|
||||
existing: Record<string, any> = {}
|
||||
): Record<string, any> {
|
||||
const props = this.getDefinition(moduleKey)?.props ?? {}
|
||||
const config: Record<string, any> = {}
|
||||
for (const [key, prop] of Object.entries(props)) {
|
||||
const current = existing[key] !== undefined ? existing[key] : prop.default
|
||||
config[key] = prop.readOnly || incoming[key] === undefined ? current : incoming[key]
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an incoming provider patch against what the module declares.
|
||||
*
|
||||
* The props are a runtime declaration read from a YAML file, so no JSON Schema can cover them.
|
||||
*
|
||||
* @returns The reason it is invalid, or null when it is fine
|
||||
*/
|
||||
validateProvider(patch: AnalyticsProviderInput): string | null {
|
||||
const definition = this.getDefinition(patch.key)
|
||||
if (!definition) {
|
||||
return `There is no analytics provider called "${patch.key}".`
|
||||
}
|
||||
for (const [key, value] of Object.entries(patch.config ?? {})) {
|
||||
const prop = definition.props[key]
|
||||
if (!prop || prop.readOnly || value === undefined) {
|
||||
continue
|
||||
}
|
||||
if (prop.enum) {
|
||||
// -> Enum entries are declared as `value` or `value|label`
|
||||
const allowed = prop.enum.map((entry) => entry.split('|')[0])
|
||||
if (!allowed.includes(`${value}`)) {
|
||||
return `"${value}" is not a valid value for ${prop.title}.`
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch (prop.type) {
|
||||
case 'boolean':
|
||||
if (typeof value !== 'boolean') {
|
||||
return `${prop.title} must be true or false.`
|
||||
}
|
||||
break
|
||||
case 'number':
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return `${prop.title} must be a number.`
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (typeof value !== 'string') {
|
||||
return `${prop.title} must be a string.`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the providers a client sent, leaving the ones it did not mention alone.
|
||||
*
|
||||
* One write for the lot: this goes into the site's config blob, and `sites.updateSite` is what
|
||||
* reloads the cache on every instance and drops the app shell fragments — without which a tag
|
||||
* turned on would not appear until the next restart.
|
||||
*/
|
||||
async updateSiteProviders(siteId: string, patches: AnalyticsProviderInput[]): Promise<number> {
|
||||
const stored = this.storedProviders(siteId)
|
||||
const providers: Record<string, { isEnabled: boolean; config: Record<string, any> }> = {}
|
||||
for (const patch of patches) {
|
||||
providers[patch.key] = {
|
||||
isEnabled: patch.isEnabled ?? stored[patch.key]?.isEnabled === true,
|
||||
config: this.buildConfig(patch.key, patch.config ?? {}, stored[patch.key]?.config ?? {})
|
||||
}
|
||||
}
|
||||
if (Object.keys(providers).length < 1) {
|
||||
return 0
|
||||
}
|
||||
await WIKI.models.sites.updateSite(siteId, { config: { analytics: { providers } } })
|
||||
return Object.keys(providers).length
|
||||
}
|
||||
|
||||
/**
|
||||
* The markup every document this site serves carries, one string per slot.
|
||||
*
|
||||
* Read off the cached site config, so this costs nothing and is current on every instance the
|
||||
* moment the screen is saved — the same reasoning as the theme injections it lands beside. Nothing
|
||||
* here varies by requester, by page or by session, which is why it can be built per request without
|
||||
* a cache of its own and why it is not part of the fragments that are cached per URL.
|
||||
*/
|
||||
injectionsFor(siteId: string | undefined): AnalyticsInjections {
|
||||
if (!siteId) {
|
||||
return NO_INJECTIONS
|
||||
}
|
||||
const stored = this.storedProviders(siteId)
|
||||
const parts: Record<Slot, string[]> = { head: [], bodyStart: [] }
|
||||
for (const definition of this.definitions) {
|
||||
if (stored[definition.key]?.isEnabled !== true) {
|
||||
continue
|
||||
}
|
||||
const config = this.buildConfig(definition.key, {}, stored[definition.key]?.config ?? {})
|
||||
if (this.missingRequired(definition, config).length > 0) {
|
||||
continue
|
||||
}
|
||||
for (const slot of SLOTS) {
|
||||
const rendered = renderTemplate(definition.code[slot], config)
|
||||
if (rendered !== null && rendered.length > 0) {
|
||||
parts[slot].push(rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
head: parts.head.join('\n '),
|
||||
bodyStart: parts.bodyStart.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of a provider's required props are empty, in declaration order.
|
||||
*
|
||||
* The same question the admin area asks of the form in front of it, so that "Google Analytics is
|
||||
* enabled but has no measurement ID" is something an administrator reads on the screen rather than
|
||||
* discovering from a tag that never fires.
|
||||
*/
|
||||
missingRequired(definition: AnalyticsDefinition, config: Record<string, any>): string[] {
|
||||
return definition.requires.filter((key) => {
|
||||
const value = config[key]
|
||||
return value === undefined || value === null || `${value}`.trim().length < 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A value as it is written into a JavaScript string literal. See `JS_ESCAPES`.
|
||||
*/
|
||||
function jsEscape(value: string): string {
|
||||
return value.replace(JS_ESCAPE_PATTERN, (char) => JS_ESCAPES[char]!)
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute a provider's configured values into one of its templates.
|
||||
*
|
||||
* @returns The markup, or null where a placeholder could not be resolved to something that would
|
||||
* parse — a `num` slot is a bare numeric literal, so a value that is not a number would produce a
|
||||
* syntax error taking every other script on the page with it. A template that resolves to nothing
|
||||
* is skipped rather than emitted broken.
|
||||
*/
|
||||
function renderTemplate(template: string, config: Record<string, any>): string | null {
|
||||
if (!template) {
|
||||
return ''
|
||||
}
|
||||
let usable = true
|
||||
const rendered = template.replace(PLACEHOLDER, (_match, context: string, key: string) => {
|
||||
const value = config[key]
|
||||
switch (context) {
|
||||
case 'num': {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) {
|
||||
usable = false
|
||||
return '0'
|
||||
}
|
||||
return `${num}`
|
||||
}
|
||||
case 'bool':
|
||||
return value === true ? 'true' : 'false'
|
||||
case 'attr':
|
||||
return htmlEscape(`${value ?? ''}`)
|
||||
default:
|
||||
return jsEscape(`${value ?? ''}`)
|
||||
}
|
||||
})
|
||||
return usable ? rendered : null
|
||||
}
|
||||
|
||||
export const analytics = new Analytics()
|
||||
@ -0,0 +1,11 @@
|
||||
head: |
|
||||
<!-- Baidu Tongji -->
|
||||
<script>
|
||||
var _hmt = _hmt || [];
|
||||
(function() {
|
||||
var hm = document.createElement("script");
|
||||
hm.src = "https://hm.baidu.com/hm.js?{{js:trackingId}}";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
</script>
|
||||
@ -0,0 +1,13 @@
|
||||
title: Baidu Tongji
|
||||
description: Baidu Tongji is the analytics service of Baidu, and the one that reports on traffic arriving from Baidu search. Its dashboard is in Chinese.
|
||||
website: https://tongji.baidu.com
|
||||
icon: '/_assets/icons/ultraviolet-baidu.svg'
|
||||
requires: ['trackingId']
|
||||
props:
|
||||
trackingId:
|
||||
type: String
|
||||
title: Tracking ID
|
||||
default: ''
|
||||
hint: The site key at the end of the tracking URL Baidu gives you, e.g. the XXXX in https://hm.baidu.com/hm.js?XXXX
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
@ -0,0 +1,9 @@
|
||||
head: |
|
||||
<!-- Microsoft Clarity -->
|
||||
<script type="text/javascript">
|
||||
(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", "{{js:projectId}}");
|
||||
</script>
|
||||
@ -0,0 +1,13 @@
|
||||
title: Microsoft Clarity
|
||||
description: Clarity is a free behaviour analytics tool from Microsoft. Alongside page views it records sessions and builds heatmaps, so it answers what readers did on a page rather than only how many arrived.
|
||||
website: https://clarity.microsoft.com
|
||||
icon: '/_assets/icons/ultraviolet-clarity.svg'
|
||||
requires: ['projectId']
|
||||
props:
|
||||
projectId:
|
||||
type: String
|
||||
title: Project ID
|
||||
default: ''
|
||||
hint: The project ID shown under Settings > Setup in the Clarity dashboard.
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
@ -0,0 +1,12 @@
|
||||
head: |
|
||||
<!-- Contentsquare (Hotjar) Tracking Code -->
|
||||
<script>
|
||||
(function(h,o,t,j,a,r){
|
||||
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
|
||||
h._hjSettings={hjid:{{num:siteId}},hjsv:6};
|
||||
a=o.getElementsByTagName('head')[0];
|
||||
r=o.createElement('script');r.async=1;
|
||||
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
|
||||
a.appendChild(r);
|
||||
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
|
||||
</script>
|
||||
@ -0,0 +1,13 @@
|
||||
title: Contentsquare
|
||||
description: Contentsquare, which Hotjar was renamed to, records how readers move through a page - heatmaps, session recordings and on-page surveys - rather than counting visits. Useful for finding the documentation nobody can follow.
|
||||
website: https://contentsquare.com
|
||||
icon: '/_assets/icons/ultraviolet-contentsquare.svg'
|
||||
requires: ['siteId']
|
||||
props:
|
||||
siteId:
|
||||
type: Number
|
||||
title: Site ID
|
||||
default: 0
|
||||
hint: The numeric site ID, found in the tracking code Contentsquare gives you. Still the Hotjar-style ID an existing account was set up with - the rename did not change how a site is installed.
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
@ -0,0 +1,12 @@
|
||||
head: |
|
||||
<!-- Elastic APM RUM -->
|
||||
<script>
|
||||
;(function(d, s, c) {
|
||||
var j = d.createElement(s),
|
||||
t = d.getElementsByTagName(s)[0]
|
||||
|
||||
j.src = '{{js:scriptUrl}}'
|
||||
j.onload = function() { elasticApm.init(c) }
|
||||
t.parentNode.insertBefore(j, t)
|
||||
})(document, 'script', {serviceName: '{{js:serviceName}}', serverUrl: '{{js:serverUrl}}', environment: '{{js:environment}}'})
|
||||
</script>
|
||||
@ -0,0 +1,34 @@
|
||||
title: Elastic APM RUM
|
||||
description: Real User Monitoring reports what the browser actually experienced - page load timings, failed requests, JavaScript errors - to an Elastic APM server. Performance monitoring rather than audience analytics.
|
||||
website: https://www.elastic.co/observability/application-performance-monitoring
|
||||
icon: '/_assets/icons/ultraviolet-elastic.svg'
|
||||
requires: ['serviceName', 'serverUrl', 'scriptUrl']
|
||||
props:
|
||||
serverUrl:
|
||||
type: String
|
||||
title: APM Server URL
|
||||
default: ''
|
||||
hint: The full URL of the APM server, including the port, e.g. https://apm.example.com:8200
|
||||
icon: dns
|
||||
order: 1
|
||||
serviceName:
|
||||
type: String
|
||||
title: Service Name
|
||||
default: 'wiki-js'
|
||||
hint: What this wiki is called in the APM UI.
|
||||
icon: rename
|
||||
order: 2
|
||||
environment:
|
||||
type: String
|
||||
title: Environment
|
||||
default: ''
|
||||
hint: Which deployment the data came from, e.g. production or staging. Optional - leave empty to report none.
|
||||
icon: geography
|
||||
order: 3
|
||||
scriptUrl:
|
||||
type: String
|
||||
title: Agent Script URL
|
||||
default: 'https://cdn.jsdelivr.net/npm/@elastic/apm-rum@5.17.5/dist/bundles/elastic-apm-rum.umd.min.js'
|
||||
hint: Where the RUM agent is loaded from. Pinned to a version on purpose - point it at a copy you host to keep the wiki off a public CDN.
|
||||
icon: link
|
||||
order: 4
|
||||
@ -0,0 +1,2 @@
|
||||
head: |
|
||||
<script defer data-site="{{attr:siteId}}" src="{{attr:scriptUrl}}"></script>
|
||||
@ -0,0 +1,20 @@
|
||||
title: Fathom Analytics
|
||||
description: Fathom Analytics is a paid, privacy-first analytics service that stores no personal data and needs no cookie banner. A single script reports page views and referrers.
|
||||
website: https://usefathom.com
|
||||
icon: '/_assets/icons/ultraviolet-fathom.svg'
|
||||
requires: ['siteId', 'scriptUrl']
|
||||
props:
|
||||
siteId:
|
||||
type: String
|
||||
title: Site ID
|
||||
default: ''
|
||||
hint: The short alphanumeric ID Fathom assigned this site, shown in the snippet it gives you.
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
scriptUrl:
|
||||
type: String
|
||||
title: Script URL
|
||||
default: 'https://cdn.usefathom.com/script.js'
|
||||
hint: Where the tracking script is loaded from. Change it only to use a custom domain configured in Fathom, which is how the script avoids ad blockers.
|
||||
icon: link
|
||||
order: 2
|
||||
@ -0,0 +1,9 @@
|
||||
head: |
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{attr:measurementId}}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '{{js:measurementId}}');
|
||||
</script>
|
||||
@ -0,0 +1,13 @@
|
||||
title: Google Analytics
|
||||
description: Google Analytics is a web analytics service offered by Google that tracks and reports website traffic. The wiki injects the GA4 gtag.js tag; Universal Analytics properties are no longer collected.
|
||||
website: https://analytics.google.com
|
||||
icon: '/_assets/icons/ultraviolet-google-analytics.svg'
|
||||
requires: ['measurementId']
|
||||
props:
|
||||
measurementId:
|
||||
type: String
|
||||
title: Measurement ID
|
||||
default: ''
|
||||
hint: The GA4 measurement ID of the data stream, found under Admin > Data streams. Starts with G-.
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
@ -0,0 +1,13 @@
|
||||
head: |
|
||||
<!-- Google Tag Manager -->
|
||||
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','{{js:containerId}}');</script>
|
||||
<!-- End Google Tag Manager -->
|
||||
bodyStart: |
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id={{attr:containerId}}"
|
||||
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
||||
<!-- End Google Tag Manager (noscript) -->
|
||||
@ -0,0 +1,13 @@
|
||||
title: Google Tag Manager
|
||||
description: Google Tag Manager loads and manages the tracking tags of other services from one container, so the tags themselves are configured at Google rather than here. Use it instead of the individual providers, not alongside them, or a page will be counted twice.
|
||||
website: https://tagmanager.google.com
|
||||
icon: '/_assets/icons/ultraviolet-google-tag-manager.svg'
|
||||
requires: ['containerId']
|
||||
props:
|
||||
containerId:
|
||||
type: String
|
||||
title: Container ID
|
||||
default: ''
|
||||
hint: The container this site loads, shown at the top of the Tag Manager workspace. Starts with GTM-.
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
@ -0,0 +1,17 @@
|
||||
head: |
|
||||
<!-- Matomo -->
|
||||
<script>
|
||||
var _paq = window._paq = window._paq || [];
|
||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function() {
|
||||
var u = "{{js:serverUrl}}/";
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php']);
|
||||
_paq.push(['setSiteId', '{{js:siteId}}']);
|
||||
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
|
||||
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
|
||||
})();
|
||||
</script>
|
||||
<noscript><p><img referrerpolicy="no-referrer-when-downgrade" src="{{attr:serverUrl}}/matomo.php?idsite={{attr:siteId}}&rec=1" style="border:0" alt=""></p></noscript>
|
||||
<!-- End Matomo -->
|
||||
@ -0,0 +1,20 @@
|
||||
title: Matomo
|
||||
description: Matomo is an open source web analytics platform that keeps the data on your own server or in a Matomo Cloud account, rather than with an advertising network. It is the closest like-for-like replacement for Google Analytics.
|
||||
website: https://matomo.org
|
||||
icon: '/_assets/icons/ultraviolet-matomo.svg'
|
||||
requires: ['serverUrl', 'siteId']
|
||||
props:
|
||||
serverUrl:
|
||||
type: String
|
||||
title: Server URL
|
||||
default: ''
|
||||
hint: The root URL of the Matomo installation, with the scheme and without a trailing slash, e.g. https://example.matomo.cloud
|
||||
icon: dns
|
||||
order: 1
|
||||
siteId:
|
||||
type: String
|
||||
title: Site ID
|
||||
default: '1'
|
||||
hint: The number Matomo assigned this site, shown under Administration > Websites > Manage.
|
||||
icon: 3d-touch
|
||||
order: 2
|
||||
@ -0,0 +1,2 @@
|
||||
head: |
|
||||
<script defer data-domain="{{attr:domain}}" src="{{attr:scriptUrl}}"></script>
|
||||
@ -0,0 +1,20 @@
|
||||
title: Plausible Analytics
|
||||
description: Plausible is a lightweight, open source analytics tool that sets no cookies and collects no personal data, so a wiki using it needs no consent banner. Available as a hosted service or self-hosted.
|
||||
website: https://plausible.io
|
||||
icon: '/_assets/icons/ultraviolet-plausible.svg'
|
||||
requires: ['domain', 'scriptUrl']
|
||||
props:
|
||||
domain:
|
||||
type: String
|
||||
title: Domain
|
||||
default: ''
|
||||
hint: The domain as it is registered in Plausible, without a scheme, e.g. wiki.example.com
|
||||
icon: website
|
||||
order: 1
|
||||
scriptUrl:
|
||||
type: String
|
||||
title: Script URL
|
||||
default: 'https://plausible.io/js/script.js'
|
||||
hint: Where the tracking script is loaded from. Change it only for a self-hosted instance, or to use one of the extension scripts such as script.hash.js.
|
||||
icon: link
|
||||
order: 2
|
||||
@ -0,0 +1,10 @@
|
||||
head: |
|
||||
<!-- StatCounter -->
|
||||
<script type="text/javascript">
|
||||
var sc_project={{num:projectId}};
|
||||
var sc_invisible=1;
|
||||
var sc_security="{{js:securityToken}}";
|
||||
</script>
|
||||
<script type="text/javascript" src="https://www.statcounter.com/counter/counter.js" async></script>
|
||||
<noscript><div class="statcounter"><img class="statcounter" src="https://c.statcounter.com/{{attr:projectId}}/0/{{attr:securityToken}}/1/" alt="" referrerpolicy="no-referrer-when-downgrade"></div></noscript>
|
||||
<!-- End StatCounter -->
|
||||
@ -0,0 +1,20 @@
|
||||
title: StatCounter
|
||||
description: StatCounter is a long-running hosted analytics service with a free tier, reporting visitors, referrers and pages viewed. It adds a no-script pixel so visits without JavaScript are counted too.
|
||||
website: https://statcounter.com
|
||||
icon: '/_assets/icons/ultraviolet-statcounter.svg'
|
||||
requires: ['projectId', 'securityToken']
|
||||
props:
|
||||
projectId:
|
||||
type: Number
|
||||
title: Project ID
|
||||
default: 0
|
||||
hint: The numeric project ID, found in the code snippet StatCounter gives you.
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
securityToken:
|
||||
type: String
|
||||
title: Security Token
|
||||
default: ''
|
||||
hint: The token beside the project ID in the same snippet. It is public - it identifies the project rather than authenticating you.
|
||||
icon: key
|
||||
order: 2
|
||||
@ -0,0 +1,2 @@
|
||||
head: |
|
||||
<script defer src="{{attr:scriptUrl}}" data-website-id="{{attr:websiteId}}"></script>
|
||||
@ -0,0 +1,20 @@
|
||||
title: Umami
|
||||
description: Umami is an open source, privacy-focused alternative to Google Analytics, with a single script and no cookies. Runs as a hosted service or on your own server.
|
||||
website: https://umami.is
|
||||
icon: '/_assets/icons/ultraviolet-umami.svg'
|
||||
requires: ['websiteId', 'scriptUrl']
|
||||
props:
|
||||
websiteId:
|
||||
type: String
|
||||
title: Website ID
|
||||
default: ''
|
||||
hint: The ID Umami assigned this site, shown on its Settings > Websites page.
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
scriptUrl:
|
||||
type: String
|
||||
title: Script URL
|
||||
default: 'https://cloud.umami.is/script.js'
|
||||
hint: Where the tracking script is loaded from. For a self-hosted instance this is your own server, e.g. https://umami.example.com/script.js
|
||||
icon: link
|
||||
order: 2
|
||||
@ -0,0 +1,16 @@
|
||||
head: |
|
||||
<!-- Yandex.Metrica counter -->
|
||||
<script type="text/javascript">
|
||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||
|
||||
ym({{num:tagNumber}}, "init", {
|
||||
clickmap:true,
|
||||
trackLinks:true,
|
||||
accurateTrackBounce:true,
|
||||
webvisor:{{bool:webvisor}}
|
||||
});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/{{attr:tagNumber}}" style="position:absolute; left:-9999px;" alt=""></div></noscript>
|
||||
<!-- /Yandex.Metrica counter -->
|
||||
@ -0,0 +1,20 @@
|
||||
title: Yandex Metrica
|
||||
description: Yandex Metrica is a free analytics service with session replay and heatmaps built in, widely used where Yandex is the main search engine.
|
||||
website: https://metrica.yandex.com
|
||||
icon: '/_assets/icons/ultraviolet-yandex-metrica.svg'
|
||||
requires: ['tagNumber']
|
||||
props:
|
||||
tagNumber:
|
||||
type: Number
|
||||
title: Tag Number
|
||||
default: 0
|
||||
hint: The numeric tag ID. When creating the tag choose "CMS and website builders" and copy the number it gives you.
|
||||
icon: 3d-touch
|
||||
order: 1
|
||||
webvisor:
|
||||
type: Boolean
|
||||
title: Session Replay
|
||||
default: false
|
||||
hint: Record what readers do on a page so it can be played back. Off by default - it captures far more about a reader than a page view does, and may need disclosing in your privacy policy.
|
||||
icon: video-playlist
|
||||
order: 2
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1003 B |
|
After Width: | Height: | Size: 880 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 595 B |
|
After Width: | Height: | Size: 740 B |
@ -1,189 +1,444 @@
|
||||
<template lang="pug">
|
||||
v-container(fluid, grid-list-lg)
|
||||
v-layout(row, wrap)
|
||||
v-flex(xs12)
|
||||
.admin-header
|
||||
img.animated.fadeInUp(src='/_assets/svg/icon-line-chart.svg', alt='Analytics', style='width: 80px;')
|
||||
.admin-header-title
|
||||
.headline.primary--text.animated.fadeInLeft {{ $t('admin.analytics.title') }}
|
||||
.subtitle-1.grey--text.animated.fadeInLeft.wait-p4s {{ $t('admin.analytics.subtitle') }}
|
||||
v-spacer
|
||||
v-btn.animated.fadeInDown.wait-p2s.mr-3(icon, outlined, color='grey', @click='refresh')
|
||||
v-icon mdi-refresh
|
||||
v-btn.animated.fadeInDown(color='success', @click='save', depressed, large)
|
||||
v-icon(left) mdi-check
|
||||
span {{$t('common.actions.apply')}}
|
||||
|
||||
v-flex(lg3, xs12)
|
||||
v-card.animated.fadeInUp
|
||||
v-toolbar(flat, color='primary', dark, dense)
|
||||
.subtitle-1 {{$t('admin.analytics.providers')}}
|
||||
v-list(two-line, dense).py-0
|
||||
template(v-for='(str, idx) in providers')
|
||||
v-list-item(:key='str.key', @click='selectedProvider = str.key', :disabled='!str.isAvailable')
|
||||
v-list-item-avatar(size='24')
|
||||
v-icon(color='grey', v-if='!str.isAvailable') mdi-minus-box-outline
|
||||
v-icon(color='primary', v-else-if='str.isEnabled', v-ripple, @click='str.isEnabled = false') mdi-checkbox-marked-outline
|
||||
v-icon(color='grey', v-else, v-ripple, @click='str.isEnabled = true') mdi-checkbox-blank-outline
|
||||
v-list-item-content
|
||||
v-list-item-title.body-2(:class='!str.isAvailable ? `grey--text` : (selectedProvider === str.key ? `primary--text` : ``)') {{ str.title }}
|
||||
v-list-item-subtitle: .caption(:class='!str.isAvailable ? `grey--text text--lighten-1` : (selectedProvider === str.key ? `blue--text ` : ``)') {{ str.description }}
|
||||
v-list-item-avatar(v-if='selectedProvider === str.key', size='24')
|
||||
v-icon.animated.fadeInLeft(color='primary', large) mdi-chevron-right
|
||||
v-divider(v-if='idx < providers.length - 1')
|
||||
|
||||
v-flex(xs12, lg9)
|
||||
|
||||
v-card.animated.fadeInUp.wait-p2s
|
||||
v-toolbar(color='primary', dense, flat, dark)
|
||||
.subtitle-1 {{provider.title}}
|
||||
v-spacer
|
||||
v-switch(
|
||||
dark
|
||||
color='blue lighten-5'
|
||||
label='Active'
|
||||
v-model='provider.isEnabled'
|
||||
hide-details
|
||||
inset
|
||||
)
|
||||
v-card-info(color='blue')
|
||||
div
|
||||
div {{provider.description}}
|
||||
span.caption: a(:href='provider.website') {{provider.website}}
|
||||
v-spacer
|
||||
.admin-providerlogo
|
||||
img(:src='provider.logo', :alt='provider.title')
|
||||
v-card-text
|
||||
v-form
|
||||
.overline.pb-5 {{$t('admin.analytics.providerConfiguration')}}
|
||||
.body-1.ml-3(v-if='!provider.config || provider.config.length < 1'): em {{$t('admin.analytics.providerNoConfiguration')}}
|
||||
template(v-else, v-for='cfg in provider.config')
|
||||
v-select(
|
||||
v-if='cfg.value.type === "string" && cfg.value.enum'
|
||||
outlined
|
||||
:items='cfg.value.enum'
|
||||
:key='cfg.key'
|
||||
:label='cfg.value.title'
|
||||
v-model='cfg.value.value'
|
||||
prepend-icon='mdi:cog-box'
|
||||
:hint='cfg.value.hint ? cfg.value.hint : ""'
|
||||
persistent-hint
|
||||
:class='cfg.value.hint ? "mb-2" : ""'
|
||||
)
|
||||
v-switch.mb-3(
|
||||
v-else-if='cfg.value.type === "boolean"'
|
||||
:key='cfg.key'
|
||||
:label='cfg.value.title'
|
||||
v-model='cfg.value.value'
|
||||
color='primary'
|
||||
prepend-icon='mdi:cog-box'
|
||||
:hint='cfg.value.hint ? cfg.value.hint : ""'
|
||||
persistent-hint
|
||||
inset
|
||||
)
|
||||
v-textarea(
|
||||
v-else-if='cfg.value.type === "string" && cfg.value.multiline'
|
||||
outlined
|
||||
:key='cfg.key'
|
||||
:label='cfg.value.title'
|
||||
v-model='cfg.value.value'
|
||||
prepend-icon='mdi:cog-box'
|
||||
:hint='cfg.value.hint ? cfg.value.hint : ""'
|
||||
persistent-hint
|
||||
:class='cfg.value.hint ? "mb-2" : ""'
|
||||
)
|
||||
v-text-field(
|
||||
v-else
|
||||
outlined
|
||||
:key='cfg.key'
|
||||
:label='cfg.value.title'
|
||||
v-model='cfg.value.value'
|
||||
prepend-icon='mdi:cog-box'
|
||||
:hint='cfg.value.hint ? cfg.value.hint : ""'
|
||||
persistent-hint
|
||||
:class='cfg.value.hint ? "mb-2" : ""'
|
||||
)
|
||||
|
||||
<template>
|
||||
<w-page class="admin-analytics">
|
||||
<div class="flex flex-wrap p-4 items-center">
|
||||
<div class="flex-none">
|
||||
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-bar-chart.svg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 pl-4">
|
||||
<div class="text-h5 admin-page-title animated fadeInLeft">
|
||||
{{ t('admin.analytics.title') }}
|
||||
</div>
|
||||
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
|
||||
{{ t('admin.analytics.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none flex items-center">
|
||||
<w-spinner class="mr-4" v-show="state.loading > 0" color="accent" size="sm" />
|
||||
<w-btn
|
||||
class="mr-2 acrylic-btn"
|
||||
icon="la:question-circle"
|
||||
flat
|
||||
color="grey"
|
||||
:aria-label="t(`common.actions.viewDocs`)"
|
||||
:href="siteStore.docsBase + `/admin/analytics`"
|
||||
target="_blank">
|
||||
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
unelevated
|
||||
icon="mdi:check"
|
||||
:label="t(`common.actions.apply`)"
|
||||
color="secondary"
|
||||
@click="save"
|
||||
:loading="state.loading > 0" />
|
||||
</div>
|
||||
</div>
|
||||
<w-separator inset />
|
||||
<!--
|
||||
The same shape as the storage and authentication screens: a list as wide as it needs to be, the
|
||||
panel taking what is left, and the panel wrapping onto its own row rather than narrowing for
|
||||
ever. The explicit floors are what make the wrapping real -- see the note in `AdminStorage.vue`.
|
||||
-->
|
||||
<div class="flex flex-wrap p-4 gap-4">
|
||||
<div class="flex-none">
|
||||
<w-card class="rounded bg-dark">
|
||||
<w-list style="min-width: 300px" padding dark>
|
||||
<w-item
|
||||
v-for="prv of state.providers"
|
||||
:key="prv.key"
|
||||
active-class="bg-primary text-white"
|
||||
:active="state.selectedProvider === prv.key"
|
||||
:to="`/_admin/` + adminStore.currentSiteId + `/analytics/` + prv.key"
|
||||
clickable>
|
||||
<w-item-section side><w-icon :name="`img:` + prv.icon" /></w-item-section>
|
||||
<w-item-section>
|
||||
<w-item-label>{{ prv.title }}</w-item-label>
|
||||
<w-item-label caption :class="subtitleColor(prv)">{{
|
||||
providerState(prv).label
|
||||
}}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<status-light :color="providerState(prv).light" :pulse="providerState(prv).pulse" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</w-card>
|
||||
</div>
|
||||
<div class="flex-1" style="min-width: min(480px, 100%)" v-if="state.provider">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex-1" style="min-width: min(420px, 100%)">
|
||||
<!-- ----------------------- -->
|
||||
<!-- Provider Configuration -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2">
|
||||
<w-card-header>{{ t('admin.analytics.providerConfiguration') }}</w-card-header>
|
||||
<w-item tag="label">
|
||||
<blueprint-icon class="self-start" icon="shutdown" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.analytics.enabled`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.analytics.enabledHint`) }}</w-item-label>
|
||||
<!-- -> Only while it is actually true of the form in front of the reader: a
|
||||
provider is turned on and then filled in, and saying so before either has
|
||||
happened would be scolding somebody for not having finished yet. -->
|
||||
<w-item-label class="text-deep-orange" v-if="missingLabels.length > 0" caption>
|
||||
{{ t('admin.analytics.missingFields', { fields: missingLabels.join(', ') }) }}
|
||||
</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section avatar>
|
||||
<w-toggle
|
||||
v-model="state.provider.isEnabled"
|
||||
:aria-label="t(`admin.analytics.enabled`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<!--
|
||||
The condition belongs on the section rather than on the text inside it: a section is
|
||||
a padded band whether or not anything renders in it, so an unconditional one would
|
||||
leave 32px of empty space under the toggle on every provider that does have props.
|
||||
-->
|
||||
<w-card-section
|
||||
v-if="!state.provider.config || Object.keys(state.provider.config).length < 1">
|
||||
<div class="text-body2 text-grey">
|
||||
{{ t('admin.analytics.providerNoConfiguration') }}
|
||||
</div>
|
||||
</w-card-section>
|
||||
<template v-for="(cfg, cfgKey) in state.provider.config" :key="cfgKey">
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item v-if="cfg.type === `boolean`" tag="label">
|
||||
<blueprint-icon class="self-start" :icon="cfg.icon" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ cfg.title }}</w-item-label>
|
||||
<w-item-label caption>{{ cfg.hint }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section avatar>
|
||||
<w-toggle v-model="cfg.value" :aria-label="cfg.title" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item v-else>
|
||||
<blueprint-icon class="self-start" :icon="cfg.icon" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ cfg.title }}</w-item-label>
|
||||
<w-item-label caption>{{ cfg.hint }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section :style="cfg.type === `number` ? `flex: 0 0 150px;` : ``">
|
||||
<w-select
|
||||
v-if="cfg.enum"
|
||||
outlined
|
||||
v-model="cfg.value"
|
||||
:options="cfg.enum"
|
||||
emit-value
|
||||
map-options
|
||||
dense
|
||||
options-dense
|
||||
:aria-label="cfg.title" />
|
||||
<!-- -> `no-autofill` on every field, as on the other two module forms: a
|
||||
password manager offers to fill whatever LOOKS like an account field, and a
|
||||
tracking ID beside a server URL is exactly that shape. -->
|
||||
<w-input
|
||||
v-else
|
||||
outlined
|
||||
v-model="cfg.value"
|
||||
dense
|
||||
no-autofill
|
||||
:type="cfg.type === `number` ? `number` : `text`"
|
||||
:aria-label="cfg.title" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
<!-- -> Only once there is more than one tag going out, which is the situation it
|
||||
describes. A wiki with a single provider on has nothing to double-count. -->
|
||||
<w-card-section v-if="activeCount > 1">
|
||||
<w-banner
|
||||
:class="dark.isActive ? `bg-orange-9 text-white` : `bg-orange-1 text-orange-9`">
|
||||
{{ t('admin.analytics.multipleWarn') }}
|
||||
</w-banner>
|
||||
</w-card-section>
|
||||
</w-card>
|
||||
</div>
|
||||
<div class="flex-none" style="width: 300px">
|
||||
<!-- ----------------------- -->
|
||||
<!-- Infobox -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="rounded">
|
||||
<w-card-section class="text-center">
|
||||
<!-- -> The module's own icon, the same one the list on the left draws it with, so a
|
||||
provider looks the same wherever this screen shows it -->
|
||||
<w-icon :name="`img:` + state.provider.icon" size="100px" />
|
||||
<div class="text-subtitle2 mt-2">{{ state.provider.title }}</div>
|
||||
<div class="text-caption mt-2">{{ state.provider.description }}</div>
|
||||
</w-card-section>
|
||||
</w-card>
|
||||
<w-btn
|
||||
v-if="state.provider.website"
|
||||
class="w-full mt-4 acrylic-btn"
|
||||
icon="la:external-link-alt"
|
||||
flat
|
||||
color="primary"
|
||||
:label="t(`admin.analytics.website`)"
|
||||
:href="state.provider.website"
|
||||
target="_blank"
|
||||
rel="noopener" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, nextTick, onMounted, reactive, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { loading } from '@/composables/loading'
|
||||
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
// STORES
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// ROUTER
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta(() => ({
|
||||
title: t('admin.analytics.title')
|
||||
}))
|
||||
|
||||
import providersQuery from 'gql/admin/analytics/analytics-query-providers.gql'
|
||||
import providersSaveMutation from 'gql/admin/analytics/analytics-mutation-save-providers.gql'
|
||||
// DATA
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
providers: [],
|
||||
selectedProvider: '',
|
||||
provider: {}
|
||||
const state = reactive({
|
||||
loading: 0,
|
||||
selectedProvider: '',
|
||||
desiredProvider: '',
|
||||
provider: null,
|
||||
providers: []
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/**
|
||||
* The titles of the selected provider's required fields that are still empty.
|
||||
*
|
||||
* Read off the form rather than off what the server last sent, so that filling the last empty field
|
||||
* clears the warning as it is typed. The server asks the same question of the stored values before it
|
||||
* renders anything — an enabled provider missing one of these contributes no tag at all, which is the
|
||||
* whole reason this is worth saying on the screen.
|
||||
*/
|
||||
const missingLabels = computed(() => {
|
||||
const provider = state.provider
|
||||
if (!provider?.isEnabled) {
|
||||
return []
|
||||
}
|
||||
return (provider.requires ?? [])
|
||||
.filter((key) => `${provider.config?.[key]?.value ?? ''}`.trim().length < 1)
|
||||
.map((key) => provider.config?.[key]?.title ?? key)
|
||||
})
|
||||
|
||||
/** How many providers the form has turned on, which is what decides the double-counting warning. */
|
||||
const activeCount = computed(() => state.providers.filter((prv) => prv.isEnabled).length)
|
||||
|
||||
// WATCHERS
|
||||
|
||||
watch(
|
||||
() => adminStore.currentSiteId,
|
||||
async (newValue) => {
|
||||
await load()
|
||||
nextTick(() => {
|
||||
router.replace(`/_admin/${newValue}/analytics/${state.selectedProvider}`)
|
||||
})
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => state.selectedProvider,
|
||||
(newValue) => {
|
||||
state.provider = state.providers.find((prv) => prv.key === newValue) || null
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => state.providers,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length > 0) {
|
||||
if (state.desiredProvider) {
|
||||
state.selectedProvider = state.desiredProvider
|
||||
state.desiredProvider = ''
|
||||
} else if (newValue.some((prv) => prv.key === state.selectedProvider)) {
|
||||
// -> Keep the current selection across a reload, since saving reloads the providers
|
||||
state.provider = newValue.find((prv) => prv.key === state.selectedProvider)
|
||||
} else {
|
||||
state.selectedProvider = newValue[0].key
|
||||
if (!route.params.id) {
|
||||
router.replace(`/_admin/${adminStore.currentSiteId}/analytics/${state.selectedProvider}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => route.params.id,
|
||||
(to) => {
|
||||
if (!to) {
|
||||
return
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedProvider(newValue, oldValue) {
|
||||
this.provider = _.find(this.providers, ['key', newValue]) || {}
|
||||
},
|
||||
providers(newValue, oldValue) {
|
||||
this.selectedProvider = 'google'
|
||||
if (state.providers.length < 1) {
|
||||
state.desiredProvider = to
|
||||
} else {
|
||||
state.selectedProvider = to
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
await this.$apollo.queries.providers.refetch()
|
||||
this.$store.commit('showNotification', {
|
||||
message: this.$t('admin.analytics.refreshSuccess'),
|
||||
style: 'success',
|
||||
icon: 'cached'
|
||||
})
|
||||
},
|
||||
async save() {
|
||||
this.$store.commit(`loadingStart`, 'admin-analytics-saveproviders')
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: providersSaveMutation,
|
||||
variables: {
|
||||
providers: this.providers
|
||||
.map((str) => _.pick(str, ['isEnabled', 'key', 'config']))
|
||||
.map((str) => ({
|
||||
...str,
|
||||
config: str.config.map((cfg) => ({
|
||||
...cfg,
|
||||
value: JSON.stringify({ v: cfg.value.value })
|
||||
}))
|
||||
}))
|
||||
}
|
||||
})
|
||||
this.$store.commit('showNotification', {
|
||||
message: this.$t('admin.analytics.saveSuccess'),
|
||||
style: 'success',
|
||||
icon: 'check'
|
||||
}
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* What a provider is doing, in the order the two questions matter.
|
||||
*
|
||||
* Turned off first, since nothing else about it applies. Then whether it has what it needs: a
|
||||
* provider with an empty tracking ID is not collecting less, it is collecting nothing — the server
|
||||
* skips it rather than serving a tag pointed at no account — so it gets the amber light that means
|
||||
* "go and look at this one" here and on the storage screen.
|
||||
*/
|
||||
function providerState(prv) {
|
||||
if (!prv.isEnabled) {
|
||||
return { label: t('admin.analytics.inactive'), light: 'negative', pulse: false }
|
||||
}
|
||||
const missing = (prv.requires ?? []).some(
|
||||
(key) => `${prv.config?.[key]?.value ?? ''}`.trim().length < 1
|
||||
)
|
||||
if (missing) {
|
||||
return { label: t('admin.analytics.incomplete'), light: 'warning', pulse: true }
|
||||
}
|
||||
return { label: t('admin.analytics.active'), light: 'positive', pulse: true }
|
||||
}
|
||||
|
||||
function subtitleColor(prv) {
|
||||
if (state.selectedProvider === prv.key) {
|
||||
return 'text-blue-2'
|
||||
} else if (prv.isEnabled) {
|
||||
return 'text-positive'
|
||||
} else {
|
||||
return 'text-grey-7'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a module prop declaration and its stored value into the shape the config editor renders,
|
||||
* expanding `value|label` enum entries into options.
|
||||
*/
|
||||
function buildConfigEditor(props, values) {
|
||||
const config = {}
|
||||
for (const [key, prop] of Object.entries(props ?? {})) {
|
||||
config[key] = {
|
||||
...prop,
|
||||
value: values?.[key] ?? prop.default,
|
||||
...(prop.enum && {
|
||||
enum: prop.enum.map((entry) => {
|
||||
const [value, label] = entry.split('|')
|
||||
return { value, label: label ?? value }
|
||||
})
|
||||
} catch (err) {
|
||||
this.$store.commit('pushGraphError', err)
|
||||
}
|
||||
this.$store.commit(`loadingStop`, 'admin-analytics-saveproviders')
|
||||
})
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
providers: {
|
||||
query: providersQuery,
|
||||
fetchPolicy: 'network-only',
|
||||
update: (data) =>
|
||||
_.cloneDeep(data.analytics.providers).map((str) => ({
|
||||
...str,
|
||||
config: _.sortBy(
|
||||
str.config.map((cfg) => ({
|
||||
...cfg,
|
||||
value: JSON.parse(cfg.value)
|
||||
})),
|
||||
[(t) => t.value.order]
|
||||
)
|
||||
})),
|
||||
watchLoading(isLoading) {
|
||||
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-analytics-refresh')
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state.loading++
|
||||
loading.show()
|
||||
try {
|
||||
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}/analytics`).json()
|
||||
state.providers = (resp?.providers ?? []).map((prv) => ({
|
||||
...prv,
|
||||
config: buildConfigEditor(prv.props, prv.config)
|
||||
}))
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.analytics.loadFailed'),
|
||||
caption: apiErrorMessage(err),
|
||||
timeout: 20000
|
||||
})
|
||||
}
|
||||
loading.hide()
|
||||
state.loading--
|
||||
}
|
||||
|
||||
/** A provider as the API expects it. Read-only props are left out — the server keeps what it holds. */
|
||||
function payloadFor(prv) {
|
||||
const config = {}
|
||||
for (const [key, cfg] of Object.entries(prv.config ?? {})) {
|
||||
if (cfg.readOnly) {
|
||||
continue
|
||||
}
|
||||
config[key] = cfg.type === 'number' ? Number(cfg.value) : cfg.value
|
||||
}
|
||||
return {
|
||||
key: prv.key,
|
||||
isEnabled: prv.isEnabled,
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save every provider at once, the way the API takes it.
|
||||
*
|
||||
* All of them rather than the selected one: they are one setting between them — the tags that go out
|
||||
* with every page — and a screen that saved only what was on it would quietly discard whatever was
|
||||
* changed on another provider before switching.
|
||||
*/
|
||||
async function save() {
|
||||
state.loading++
|
||||
loading.show()
|
||||
try {
|
||||
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}/analytics`, {
|
||||
json: {
|
||||
providers: state.providers.map(payloadFor)
|
||||
}
|
||||
}).json()
|
||||
if (!resp?.ok) {
|
||||
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||
}
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.analytics.saveSuccess')
|
||||
})
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.analytics.saveFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
loading.hide()
|
||||
state.loading--
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
if (!state.selectedProvider && route.params.id) {
|
||||
state.desiredProvider = route.params.id
|
||||
}
|
||||
if (adminStore.currentSiteId) {
|
||||
load()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,260 +0,0 @@
|
||||
<template lang="pug">
|
||||
v-container(fluid, grid-list-lg)
|
||||
v-layout(row wrap)
|
||||
v-flex(xs12)
|
||||
.admin-header
|
||||
img.animated.fadeInUp(src='/_assets/svg/icon-tags.svg', alt='Tags', style='width: 80px;')
|
||||
.admin-header-title
|
||||
.headline.primary--text.animated.fadeInLeft {{$t('tags.title')}}
|
||||
.subtitle-1.grey--text.animated.fadeInLeft.wait-p4s {{$t('tags.subtitle')}}
|
||||
v-spacer
|
||||
v-btn.animated.fadeInDown(outlined, color='grey', @click='refresh', icon)
|
||||
v-icon mdi-refresh
|
||||
v-container.pa-0.mt-3(fluid, grid-list-lg)
|
||||
v-layout(row)
|
||||
v-flex(style='flex: 0 0 350px;')
|
||||
v-card.animated.fadeInUp
|
||||
v-toolbar(:color='$vuetify.theme.dark ? `grey darken-3-d5` : `grey lighten-4`', flat)
|
||||
v-text-field(
|
||||
v-model='filter'
|
||||
:label='$t(`admin.tags.filter`)'
|
||||
hide-details
|
||||
single-line
|
||||
solo
|
||||
flat
|
||||
dense
|
||||
color='teal'
|
||||
:background-color='$vuetify.theme.dark ? `grey darken-4` : `grey lighten-2`'
|
||||
prepend-inner-icon='mdi:magnify'
|
||||
)
|
||||
v-divider
|
||||
v-list.py-2(dense, nav)
|
||||
v-list-item(v-if='tags.length < 1')
|
||||
v-list-item-avatar(size='24'): v-icon(color='grey') mdi-compass-off
|
||||
v-list-item-content
|
||||
.caption.grey--text {{$t('tags.emptyList')}}
|
||||
v-list-item(
|
||||
v-for='tag of filteredTags'
|
||||
:key='tag.id'
|
||||
:class='(tag.id === current.id) ? "teal" : ""'
|
||||
@click='selectTag(tag)'
|
||||
)
|
||||
v-list-item-avatar(size='24', tile): v-icon(size='18', :color='tag.id === current.id ? `white` : `teal`') mdi-tag
|
||||
v-list-item-title(:class='tag.id === current.id ? `white--text` : ``') {{tag.tag}}
|
||||
v-flex.animated.fadeInUp.wait-p2s
|
||||
template(v-if='current.id')
|
||||
v-card
|
||||
v-toolbar(dense, color='teal', flat, dark)
|
||||
.subtitle-1 {{$t('tags.edit')}}
|
||||
v-spacer
|
||||
v-btn.pl-4(
|
||||
color='white'
|
||||
dark
|
||||
outlined
|
||||
small
|
||||
:href='`/t/` + current.tag'
|
||||
)
|
||||
span.text-none {{$t('admin.tags.viewLinkedPages')}}
|
||||
v-icon(right) mdi-chevron-right
|
||||
v-card-text
|
||||
v-text-field(
|
||||
outlined
|
||||
:label='$t("tags.tag")'
|
||||
prepend-icon='mdi:tag'
|
||||
v-model='current.tag'
|
||||
counter='255'
|
||||
)
|
||||
v-text-field(
|
||||
outlined
|
||||
:label='$t("tags.label")'
|
||||
prepend-icon='mdi:format-title'
|
||||
v-model='current.title'
|
||||
hide-details
|
||||
)
|
||||
v-card-chin
|
||||
i18next.caption.pl-3(path='admin.tags.date', tag='div')
|
||||
strong(place='created') {{current.createdAt | moment('from')}}
|
||||
strong(place='updated') {{current.updatedAt | moment('from')}}
|
||||
v-spacer
|
||||
v-dialog(v-model='deleteTagDialog', max-width='500')
|
||||
template(v-slot:activator='{ on }')
|
||||
v-btn(color='red', outlined, v-on='on')
|
||||
v-icon(color='red') mdi-trash-can-outline
|
||||
v-card
|
||||
.dialog-header.is-red {{$t('admin.tags.deleteConfirm')}}
|
||||
v-card-text.pa-4
|
||||
i18next(tag='span', path='admin.tags.deleteConfirmText')
|
||||
strong(place='tag') {{ current.tag }}
|
||||
v-card-actions
|
||||
v-spacer
|
||||
v-btn(text, @click='deleteTagDialog = false') {{$t('common.actions.cancel')}}
|
||||
v-btn(color='red', dark, @click='deleteTag(current)') {{$t('common.actions.delete')}}
|
||||
v-btn.px-5.mr-2(color='success', depressed, dark, @click='saveTag(current)')
|
||||
v-icon(left) mdi-content-save
|
||||
span {{$t('common.actions.save')}}
|
||||
v-card(v-else)
|
||||
v-card-text.grey--text(v-if='tags.length > 0') {{$t('tags.noSelectionText')}}
|
||||
v-card-text.grey--text(v-else) {{$t('tags.noItemsText')}}
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
tags: [],
|
||||
current: {},
|
||||
filter: '',
|
||||
deleteTagDialog: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTags() {
|
||||
if (this.filter.length > 0) {
|
||||
return _.filter(
|
||||
this.tags,
|
||||
(t) => t.tag.indexOf(this.filter) >= 0 || t.title.indexOf(this.filter) >= 0
|
||||
)
|
||||
} else {
|
||||
return this.tags
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectTag(tag) {
|
||||
this.current = tag
|
||||
},
|
||||
async deleteTag(tag) {
|
||||
this.$store.commit(`loadingStart`, 'admin-tags-delete')
|
||||
try {
|
||||
const resp = await this.$apollo.mutate({
|
||||
mutation: `
|
||||
mutation ($id: Int!) {
|
||||
pages {
|
||||
deleteTag (id: $id) {
|
||||
responseResult {
|
||||
succeeded
|
||||
errorCode
|
||||
slug
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id: tag.id
|
||||
}
|
||||
})
|
||||
if (_.get(resp, 'data.pages.deleteTag.responseResult.succeeded', false)) {
|
||||
this.$store.commit('showNotification', {
|
||||
message: this.$t('tags.deleteSuccess'),
|
||||
style: 'success',
|
||||
icon: 'check'
|
||||
})
|
||||
this.refresh()
|
||||
} else {
|
||||
throw new Error(
|
||||
_.get(
|
||||
resp,
|
||||
'data.pages.deleteTag.responseResult.message',
|
||||
'An unexpected error occurred.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
this.$store.commit('pushGraphError', err)
|
||||
}
|
||||
this.deleteTagDialog = false
|
||||
this.$store.commit(`loadingStop`, 'admin-tags-delete')
|
||||
},
|
||||
async saveTag(tag) {
|
||||
this.$store.commit(`loadingStart`, 'admin-tags-save')
|
||||
try {
|
||||
const resp = await this.$apollo.mutate({
|
||||
mutation: `
|
||||
mutation ($id: Int!, $tag: String!, $title: String!) {
|
||||
pages {
|
||||
updateTag (id: $id, tag: $tag, title: $title) {
|
||||
responseResult {
|
||||
succeeded
|
||||
errorCode
|
||||
slug
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id: tag.id,
|
||||
tag: tag.tag,
|
||||
title: tag.title
|
||||
}
|
||||
})
|
||||
if (_.get(resp, 'data.pages.updateTag.responseResult.succeeded', false)) {
|
||||
this.$store.commit('showNotification', {
|
||||
message: this.$t('tags.saveSuccess'),
|
||||
style: 'success',
|
||||
icon: 'check'
|
||||
})
|
||||
this.current.updatedAt = new Date()
|
||||
} else {
|
||||
throw new Error(
|
||||
_.get(
|
||||
resp,
|
||||
'data.pages.updateTag.responseResult.message',
|
||||
'An unexpected error occurred.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
this.$store.commit('pushGraphError', err)
|
||||
}
|
||||
this.$store.commit(`loadingStop`, 'admin-tags-save')
|
||||
},
|
||||
async refresh() {
|
||||
await this.$apollo.queries.tags.refetch()
|
||||
this.current = {}
|
||||
this.$store.commit('showNotification', {
|
||||
message: this.$t('tags.refreshSuccess'),
|
||||
style: 'success',
|
||||
icon: 'cached'
|
||||
})
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
tags: {
|
||||
query: `
|
||||
{
|
||||
pages {
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
title
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
fetchPolicy: 'network-only',
|
||||
update: (data) => _.cloneDeep(data.pages.tags),
|
||||
watchLoading(isLoading) {
|
||||
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-tags-refresh')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(mc('blue', '500'), 0.25);
|
||||
}
|
||||
}
|
||||
</style>
|
||||