feat: add analytics modules

scarlett
NGPixel 18 hours ago
parent b11bf4f601
commit 9ec8070c68
No known key found for this signature in database

@ -79,7 +79,8 @@ path in silence.
- `modules/` — pluggable extensions, discovered from disk. Each module is a directory with a
`definition.yml` (key, title, props/config schema) plus its implementation — e.g.
`modules/authentication/local/`. `modules/storage/*` ships `db` and `disk` — see
[Storage targets](#storage-targets).
[Storage targets](#storage-targets). `modules/analytics/*` is the odd one out: a pair of YAML files
and no implementation at all — see [Analytics](#analytics).
- `tasks/simple/` — jobs run in-process by the scheduler; each exports `task()`. File name is
kebab-case, the task key is its camelCase form.
- `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal
@ -878,6 +879,67 @@ What already existed and is unchanged: `controllers/rootFiles.ts` serves `robots
`sitemap.xml` (with `hreflang` alternates), so **discovery** was never the missing half — the
document was.
### Analytics
A tracking tag from one of a dozen third-party services, turned on per site under **Admin →
Analytics**. `models/analytics.ts`, `api/analytics.ts` and `modules/analytics/<key>/`.
**A module here is two YAML files and nothing else.** `definition.yml` declares what the provider is
and what it needs configured (the same `props` shape every other module type uses, read through
`parseModuleProps`), and `code.yml` holds the markup it contributes. There is no `analytics.ts`
beside them and there is nothing to load: the whole of what a provider does happens in the reader's
browser, so the wiki's only job is to put the right string in the right place. Unlike
`modules/storage/`, a directory that cannot be read is skipped with a warning rather than emptying
the list — a provider nobody can turn on is better than every site's existing tags going quiet.
**The markup is served, never injected by the app.** It goes into the document `renderAppShell`
hands out, so it is in the HTML of every response — including the one a client that will not run
JavaScript receives. That is the point rather than an implementation detail: several providers verify
an installation by fetching the page and looking for their snippet, which a tag the SPA adds after
boot would fail, and a tag that arrives after boot has already missed the page load it exists to
measure. `code.yml` has two slots, `head` and `bodyStart`; the second exists only because Google Tag
Manager's `<noscript>` fallback is an `<iframe>` and so cannot go in the head. The analytics head goes
in ahead of the theme's own head injection — a tag runs as early as it can, and the theme field is an
override.
**The administration area is the exception and gets no tag.** What happens under `/_admin` is the
wiki being configured rather than read, and it has no business in a report of what a site's readers
looked at — nor in whatever a session-replay provider would make of somebody typing a credential into
an authentication strategy. `analyticsInjections` sorts documents by their own URL and nothing else,
so it is a hard navigation to an admin path that comes back clean; walking into the admin area
through the app still carries whatever tag the document it started from loaded, and there is no
taking that back without the per-provider client-side layer described below.
**The consequence is that a provider sees the initial document load and no more.** This is a single
page app, so moving between wiki pages is a router transition and not a navigation; a provider that
reports views on its own (`gtag`'s page_view, Plausible's automatic pageview) will count one per hard
navigation. There is no per-provider client-side layer dispatching a view per route change, and
adding one means writing a dispatch for each provider's own API.
**Configuration lives in the site's config blob**, under `analytics.providers`, keyed by module —
not in a table. Every request that produces a document needs it, `WIKI.sites` already holds the site
configurations in memory on every instance, and `sites.updateSite` already reloads them across the
cluster and drops the app shell cache. So a tag costs no query and a saved change applies to the next
request.
**Values are escaped by context, declared in the template.** A placeholder is `{{js:prop}}`,
`{{attr:prop}}`, `{{num:prop}}` or `{{bool: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 below it. The `js` escape also covers `<`, `>` and `&` as `\uXXXX`: the contents
of a `<script>` are not parsed for entities, but the HTML parser still ends the element at
`</script`. This is about **correctness**, not privilege — `manage:sites` is the trust boundary here,
the same as for the raw head and body fields under **Admin → Theme** that this markup lands beside.
**An enabled provider with an empty required prop renders nothing at all.** `requires` in the
definition names the props that must be filled; a tag carrying an empty tracking ID is not collecting
less, it is reporting to nothing, so the provider is skipped and the admin area names the empty field
instead. A `num` placeholder that is not a number drops its whole snippet for the same reason — a bare
`var x=;` would take every other script on the page with it.
**Nothing here can be `sensitive`.** Every value 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. This
is why `api/analytics.ts` is the one module-prop surface with no `maskSensitiveProps` on the way out.
### Audit log
Every action a **person** takes is one row in `auditLog``userId`, `clientIP`, `ts`, `kind`

@ -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

@ -5,6 +5,7 @@ import type { FastifyInstance } from 'fastify'
*/
async function routes(app: FastifyInstance) {
// Register schemas
await import('./schemas/analytics.ts').then((m) => m.registerSchemas(app))
await import('./schemas/apiKey.ts').then((m) => m.registerSchemas(app))
await import('./schemas/approval.ts').then((m) => m.registerSchemas(app))
await import('./schemas/asset.ts').then((m) => m.registerSchemas(app))
@ -28,6 +29,7 @@ async function routes(app: FastifyInstance) {
await import('./schemas/user.ts').then((m) => m.registerSchemas(app))
// Register routes
app.register(import('./analytics.ts'))
app.register(import('./apiKeys.ts'), { prefix: '/api-keys' })
app.register(import('./approvals.ts'))
app.register(import('./assets.ts'))

@ -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']
})
}

@ -1,5 +1,6 @@
import * as cheerio from 'cheerio'
import type { FastifyRequest } from 'fastify'
import type { AnalyticsInjections } from '../models/analytics.ts'
import type { PageDescription } from '../models/pages.ts'
import { htmlEscape, isPageUrl, normalizePagePath, originOf, splitLocalePath } from './common.ts'
@ -71,6 +72,15 @@ const HOME_PATH = 'home'
/** The element the injected copy is wrapped in. `frontend/index.html` styles it; `main.js` removes it. */
const PRERENDER_ID = 'wiki-prerender'
/**
* The prefix of every URL belonging to the administration area.
*
* Documents served for a path below it carry no analytics tag see `analyticsInjections`. Only the
* path matters, not who is asking: the question is which document is being built, and a reader with
* no access to the admin area gets the same document at that URL as an administrator does.
*/
const ADMIN_PATH_PREFIX = '/_admin'
/**
* The `<style>` a site's CSS override is injected as, and the id BOTH sides use for it.
*
@ -464,6 +474,32 @@ async function fragmentsForBrowser(
* that makes a screen unusable: injected CSS that came with the document survives client-side
* navigation, so returning to the theme screen through the app carries it along.
*/
/**
* The analytics tags a site has turned on, for the document at this URL.
*
* The providers configured under **Admin Analytics**, rendered by `models/analytics.ts` and read
* per request off the cached site config the same reasoning as `themeInjections`, which this lands
* beside. It is the whole of how a tag is served: it belongs in the document the server hands out
* rather than in something the app adds once it has booted, because several providers verify an
* installation by fetching the page and looking for their snippet, and a tag that arrives after boot
* has already missed the page load it exists to measure.
*
* **Nothing is injected for the administration area.** What an administrator does in `/_admin` is the
* wiki being configured rather than the wiki being read, and it has no business in a report of what a
* site's readers looked at nor in whatever a session-replay provider would make of somebody typing
* a credential into an authentication strategy. This only sorts documents by their own URL: it is a
* hard navigation to an admin path that comes back without a tag, while walking into the admin area
* through the app carries whatever tag the document it started from already loaded. There is no
* getting that back without a per-provider way to stop one, which is the client-side layer this
* deliberately does not have.
*/
function analyticsInjections(siteId: string | undefined, urlPath: string): AnalyticsInjections {
if (urlPath === ADMIN_PATH_PREFIX || urlPath.startsWith(`${ADMIN_PATH_PREFIX}/`)) {
return { head: '', bodyStart: '' }
}
return WIKI.models.analytics.injectionsFor(siteId)
}
function themeInjections(siteId: string | undefined): { head: string; body: string } {
const theme = siteId ? WIKI.sites[siteId]?.config?.theme : undefined
const css: string = theme?.injectCSS?.trim() ?? ''
@ -487,7 +523,14 @@ function themeInjections(siteId: string | undefined): { head: string; body: stri
*
* The site's own theme injections travel with it (`themeInjections`), which is what puts the CSS
* override and the head and body HTML from **Admin Theme** into the document the head ones after
* everything describing the page, so that an override is the last stylesheet in the document.
* everything describing the page, so that an override is the last stylesheet in the document. So do
* the analytics tags of whichever providers the site has turned on (`analyticsInjections`), for the
* same reasons and read the same way except in the administration area, which is configuration
* rather than reading and is left out of a site's traffic entirely.
*
* The analytics head goes in FIRST, ahead of the theme's own head injection. A tracking tag is meant
* to run as early as it can, and the theme field is the operator's own markup last is where an
* override belongs.
*
* Only the public half is cached, and the shell is never cached: the shell is re-read per request so
* that `npm run build` in `frontend/` takes effect immediately, which a cached whole document would
@ -513,8 +556,19 @@ export async function renderAppShell(
*/
const withoutTitle = shell.replace(/[ \t]*<title>[\s\S]*?<\/title>\n?/i, '')
const injected = themeInjections(siteId)
const head = [fragments.head, injected.head].filter(Boolean).join('\n ')
const html = withoutTitle
const tags = analyticsInjections(siteId, urlPath)
const head = [fragments.head, tags.head, injected.head].filter(Boolean).join('\n ')
/*
Immediately after the opening `<body>`, which is the one slot that is not the end of something:
Google Tag Manager's `<noscript>` fallback is an `<iframe>`, so it cannot go in the head, and it
is specified to go there. Matched as a tag rather than as a literal string because the shell's
own carries a class and matched against the shell BEFORE anything is put into its head, so that
`<body` written into a theme's head injection cannot be what the tag lands after.
*/
const withBody = withoutTitle.replace(/<body[^>]*>/i, (match) =>
tags.bodyStart ? `${match}\n${tags.bodyStart}` : match
)
const html = withBody
.replace('</head>', () => ` ${head}\n </head>`)
.replace('</body>', () => `${fragments.body}${injected.body}</body>`)

@ -205,6 +205,10 @@ async function postBoot() {
await WIKI.models.storage.refreshFromDisk()
await WIKI.models.storage.syncAllSites()
// -> No per-site rows to create: what a site has turned on lives in its own config blob, which the
// sites cache above already holds
await WIKI.models.analytics.refreshFromDisk()
// -> Optional third-party tooling: report what is available, since features silently degrade
// without it
await WIKI.models.extensions.refreshFromDisk()

@ -1,12 +1,21 @@
{
"admin.adminArea": "Administration Area",
"admin.analytics.active": "Collecting",
"admin.analytics.enabled": "Active",
"admin.analytics.enabledHint": "Serve this provider's tracking code with every page of this site.",
"admin.analytics.inactive": "Inactive",
"admin.analytics.incomplete": "Incomplete",
"admin.analytics.loadFailed": "Could not load the analytics configuration.",
"admin.analytics.missingFields": "Nothing will be collected until these are filled in: {fields}",
"admin.analytics.multipleWarn": "Every active provider adds its own tracking code. Turning on a tag manager as well as the tags it already loads will count each page view twice.",
"admin.analytics.providerConfiguration": "Provider Configuration",
"admin.analytics.providerNoConfiguration": "This provider has no configuration options you can modify.",
"admin.analytics.providerNoConfiguration": "This provider has nothing to configure.",
"admin.analytics.providers": "Providers",
"admin.analytics.refreshSuccess": "List of providers refreshed successfully.",
"admin.analytics.saveSuccess": "Analytics configuration saved successfully",
"admin.analytics.saveFailed": "Could not save the analytics configuration.",
"admin.analytics.saveSuccess": "Analytics configuration saved successfully.",
"admin.analytics.subtitle": "Add analytics and tracking tools to your wiki",
"admin.analytics.title": "Analytics",
"admin.analytics.website": "Visit Website",
"admin.api.copyFailed": "Could not copy the key to the clipboard.",
"admin.api.copyKeyTitle": "Copy API Key",
"admin.api.copySuccess": "API key copied to the clipboard.",

@ -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()

@ -107,6 +107,7 @@ export const AUDIT_ACTIONS = {
'updateSiteImage',
'deleteSiteImage',
'updateStorage',
'updateAnalytics',
'runStorageAction',
'updateFlags',
'updateSecurity',

@ -1,3 +1,4 @@
import { analytics } from './analytics.ts'
import { apiKeys } from './apiKeys.ts'
import { approvals } from './approvals.ts'
import { assets } from './assets.ts'
@ -31,6 +32,7 @@ import { tree } from './tree.ts'
import { users } from './users.ts'
export default {
analytics,
apiKeys,
approvals,
assets,

@ -212,6 +212,11 @@ class Sites {
localePrefix: true,
syncInterval: '5m',
directAccessFallback: 'stream'
},
// -> Keyed by the directory name under `modules/analytics`. Empty until an administrator
// turns a provider on; the model completes each one from the module's declared props.
analytics: {
providers: {}
}
},
config
@ -475,6 +480,9 @@ class Sites {
localePrefix: true,
syncInterval: '5m',
directAccessFallback: 'stream'
},
analytics: {
providers: {}
}
}
})

@ -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}}&amp;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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M9.154 0C7.71 0 6.54 1.658 6.54 3.707c0 2.051 1.171 3.71 2.615 3.71 1.446 0 2.614-1.659 2.614-3.71C11.768 1.658 10.6 0 9.154 0zm7.025.594C14.86.58 13.347 2.589 13.2 3.927c-.187 1.745.25 3.487 2.179 3.735 1.933.25 3.175-1.806 3.422-3.364.252-1.555-.995-3.364-2.362-3.674a1.218 1.218 0 0 0-.261-.03zM3.582 5.535a2.811 2.811 0 0 0-.156.008c-2.118.19-2.428 3.24-2.428 3.24-.287 1.41.686 4.425 3.297 3.864 2.617-.561 2.262-3.68 2.183-4.362-.125-1.018-1.292-2.773-2.896-2.75zm16.534 1.753c-2.308 0-2.617 2.119-2.617 3.616 0 1.43.121 3.425 2.988 3.362 2.867-.063 2.553-3.238 2.553-3.988 0-.745-.62-2.99-2.924-2.99zm-8.264 2.478c-1.424.014-2.708.925-3.323 1.947-1.118 1.868-2.863 3.05-3.112 3.363-.25.309-3.61 2.116-2.864 5.42.746 3.301 3.365 3.237 3.365 3.237s1.93.19 4.171-.31c2.24-.495 4.17.123 4.17.123s5.233 1.748 6.665-1.616c1.43-3.364-.808-5.109-.808-5.109s-2.99-2.306-4.736-4.798c-1.072-1.665-2.348-2.268-3.528-2.257zm-2.234 3.84l1.542.024v8.197H7.758c-1.47-.291-2.055-1.292-2.13-1.462-.072-.173-.488-.976-.268-2.343.635-2.049 2.447-2.196 2.447-2.196h1.81zm3.964 2.39v3.881c.096.413.612.488.612.488h1.614v-4.343h1.689v5.782h-3.915c-1.517-.39-1.59-1.465-1.59-1.465v-4.317zm-5.458 1.147c-.66.197-.978.708-1.05.928-.076.22-.247.78-.1 1.269.294 1.095 1.248 1.144 1.248 1.144h1.37v-3.34z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><defs><clipPath id="clarity-outline"><path d="M17.5693 6.5287 A4.8765 4.8765 0 0 1 22.4307 6.5287 L37.147 32.1231 A4.8765 4.8765 0 0 1 34.7012 36.3506 L5.2988 36.3506 A4.8765 4.8765 0 0 1 2.853 32.1231 Z"/></clipPath></defs><path fill="#fff" d="M17.5693 6.5287 A4.8765 4.8765 0 0 1 22.4307 6.5287 L37.147 32.1231 A4.8765 4.8765 0 0 1 34.7012 36.3506 L5.2988 36.3506 A4.8765 4.8765 0 0 1 2.853 32.1231 Z"/><g clip-path="url(#clarity-outline)"><path fill="#dff0fe" d="M8.741 22.008 L31.9761 15.4677 L45.745 39.2191 Z"/><path fill="#98ccfd" d="M8.741 22.008 L45.745 39.2191 L-1.2414 39.2191 Z"/></g><g fill="none" stroke="#4788c7" stroke-linejoin="round" stroke-linecap="round"><path d="M8.741 22.008 L28.1036 16.5578"/><path d="M8.741 22.008 L34.7012 36.3506"/><path d="M17.5693 6.5287 A4.8765 4.8765 0 0 1 22.4307 6.5287 L37.147 32.1231 A4.8765 4.8765 0 0 1 34.7012 36.3506 L5.2988 36.3506 A4.8765 4.8765 0 0 1 2.853 32.1231 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><rect x="2.5" y="2.5" width="35" height="35" rx="7" fill="#98ccfd" stroke="#4788c7" stroke-linejoin="round"/><g transform="translate(2,2) scale(2.25)"><path fill="#fff" d="M3.77002 4.75781C4.57002 4.75781 5.06002 4.98448 5.52335 5.44781C5.62335 5.54781 5.69669 5.53781 5.78669 5.44781C6.25002 4.98448 6.89335 4.75781 7.83669 4.75781C8.82669 4.75781 9.47002 4.98448 9.93335 5.44781C10.0234 5.53781 10.1067 5.53781 10.1967 5.44781C10.66 4.98448 11.1967 4.75781 11.9934 4.75781C13.8367 4.75781 15.09 6.07448 15.09 7.97115C15.09 8.77781 14.8534 9.48781 14.4467 10.0311C14.43 10.0578 14.43 10.1045 14.4567 10.1311L15.3 10.9945C15.3734 11.0778 15.3167 11.2111 15.21 11.2111H12.1767C11.27 11.2211 10.7334 11.0211 10.27 10.5578C10.1867 10.4745 10.09 10.4678 9.99669 10.5578C9.53335 11.0211 8.90669 11.2478 7.95335 11.2478C6.95335 11.2478 6.29335 11.0211 5.83002 10.5578C5.74002 10.4678 5.65669 10.4578 5.56669 10.5578C5.13002 11.0211 4.54002 11.2478 3.77002 11.2478C1.93002 11.2511 0.666687 9.92448 0.666687 8.00115C0.666687 6.07448 1.93002 4.75781 3.77002 4.75781ZM2.73335 10.1145H4.95669C5.13002 10.1145 5.26669 9.96781 5.26669 9.80448L5.27002 9.03448C5.27002 8.96115 5.33335 8.89781 5.40669 8.89781H6.20669C6.28002 8.89781 6.34335 8.96115 6.34335 9.03448V9.80448C6.34335 9.97781 6.49002 10.1145 6.66002 10.1145H9.12002C9.30002 10.1145 9.43669 10.0778 9.43669 9.85115V9.34115C9.43669 9.09448 9.36335 8.97781 9.12669 8.91448L6.83002 8.27781C5.76002 7.97781 5.26002 7.23448 5.26002 6.40781V6.18115C5.26002 5.99115 5.13335 5.86448 4.96002 5.86448H2.73669C2.27335 5.86448 1.91002 6.22781 1.91002 6.69115L1.90669 9.29781C1.91002 9.75115 2.28002 10.1145 2.73335 10.1145ZM13.1134 10.1145L12.05 9.04448C11.9867 8.98115 11.9967 8.91781 12.06 8.85448L12.5334 8.37448C12.5967 8.31115 12.67 8.32781 12.7234 8.38448L13.5767 9.24781C13.6767 9.34781 13.85 9.28448 13.85 9.13781V6.68781C13.85 6.23448 13.4767 5.87115 13.0234 5.87115H10.7534C10.58 5.87115 10.4367 6.00781 10.4367 6.18115V6.93448C10.4367 7.00781 10.3734 7.07115 10.3 7.07115H9.49335C9.42002 7.07115 9.35669 7.00781 9.35669 6.93448V6.20115C9.35669 6.01115 9.21002 5.86448 9.02002 5.86448H6.64002C6.46669 5.86448 6.33002 5.99115 6.33002 6.16448V6.70781C6.33002 6.92448 6.43002 7.04448 6.61002 7.09781L8.82669 7.72448C10.0067 8.06115 10.5334 8.75781 10.5334 9.64781V9.80115C10.5334 10.0011 10.66 10.1111 10.86 10.1111H13.1134V10.1145Z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M20.345 16.33l-3.959-.926-1.05-2.01 5.177-4.535a3.962 3.962 0 012.559 3.702 4.006 4.006 0 01-2.727 3.77m-2.976 4.68c-.616 0-1.22-.207-1.714-.587l.782-4.077 3.596.841c.115.31.172.642.172.987a2.839 2.839 0 01-2.836 2.836m-2.637-.586a5.92 5.92 0 01-4.908 2.6A5.947 5.947 0 014 15.905l5.167-4.67 5.272 2.403 1.167 2.23zM.928 11.443a4.007 4.007 0 012.726-3.77l3.95.933.927 1.98-5.05 4.565a3.97 3.97 0 01-2.553-3.708m5.703-8.45a2.841 2.841 0 011.723.58l-.789 4.092-3.598-.85a2.842 2.842 0 01-.172-.986A2.84 2.84 0 016.63 2.992m2.66.59A5.92 5.92 0 0120.1 6.93c0 .4-.038.781-.114 1.164l-5.299 4.643-5.251-2.394-1.026-2.19zM24 12.571a4.723 4.723 0 00-3.124-4.454 6.695 6.695 0 00.126-1.29A6.789 6.789 0 0014.22.047 6.769 6.769 0 008.727 2.86a3.586 3.586 0 00-2.204-.754A3.604 3.604 0 003.15 6.959 4.786 4.786 0 000 11.431 4.727 4.727 0 003.139 15.9a6.876 6.876 0 00-.124 1.289 6.773 6.773 0 006.765 6.765c2.19 0 4.22-1.052 5.49-2.824a3.568 3.568 0 002.207.769 3.603 3.603 0 003.374-4.854A4.785 4.785 0 0024 12.572"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M14.185 0c-1.702.008-3.693.467-6.068 1.331C.115 4.244-1.581 7.881 1.33 15.883c2.912 8.002 6.55 9.698 14.552 6.786 8.002-2.913 9.699-6.55 6.786-14.552C20.62 2.491 18.214-.018 14.185 0zm2.77 6.57h1.253a.25.25 0 01.199.098.25.25 0 01.043.217L15.672 17.22a.25.25 0 01-.241.186h-1.254a.25.25 0 01-.242-.315l.169-.628.123-.457 2.486-9.252a.25.25 0 01.241-.185zm-9.184.808h.504a.25.25 0 01.25.25v.844a.25.25 0 01-.25.25h-.428a1.7 1.7 0 00-.258.012.221.221 0 00-.12.048.197.197 0 00-.049.078.886.886 0 00-.043.315v.641h.898a.25.25 0 01.25.25v.844a.25.25 0 01-.25.25h-.898v5.094a.25.25 0 01-.25.25h-.985a.25.25 0 01-.25-.25v-7.23a1.723 1.723 0 01.169-.78 1.395 1.395 0 01.453-.523c.37-.257.826-.341 1.257-.343zm3.85 2.344c.767 0 1.419.218 1.883.622.465.404.725.994.723 1.668v1.683l-.755 2.809h-.48a.25.25 0 01-.25-.25v-.187a1.84 1.84 0 01-.223.167c-.335.213-.79.352-1.39.352a2.936 2.936 0 01-1.337-.29 1.898 1.898 0 01-.883-.907 2.193 2.193 0 01-.187-.916 1.907 1.907 0 01.245-.99 1.724 1.724 0 01.646-.618c.52-.293 1.16-.396 1.788-.48H11.4c.342-.046.616-.075.827-.103a1.968 1.968 0 00.431-.088.147.147 0 00.065-.04l.01-.021a.319.319 0 00.009-.086v-.035a.809.809 0 00-.274-.638c-.178-.155-.458-.26-.847-.261-.385 0-.686.106-.89.262a.821.821 0 00-.338.588.25.25 0 01-.249.228H9.101a.25.25 0 01-.25-.261 2.139 2.139 0 01.825-1.593c.491-.391 1.165-.615 1.945-.615zm1.121 3.783c-.09.024-.187.047-.296.068-.303.06-.67.113-1.025.163a2.855 2.855 0 00-.692.171c-.196.082-.333.186-.407.308a.569.569 0 00-.08.307v.007a.604.604 0 00.062.275.554.554 0 00.176.198c.16.115.428.194.79.194.56-.002.915-.164 1.14-.39.223-.228.33-.542.332-.896v-.404z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#98ccfd" d="M22.84 2.9982v17.9987c0.0086 1.6473 -1.3197 2.9897 -2.967 2.9984a2.9808 2.9808 0 0 1 -0.3677 -0.0208c-1.528 -0.226 -2.6477 -1.5558 -2.6105 -3.1V3.1204c-0.0369 -1.5458 1.0856 -2.8762 2.6157 -3.1c1.6361 -0.1915 3.1178 0.9796 3.3093 2.6158c0.014 0.1201 0.0208 0.241 0.0202 0.3619z"/><path fill="#dff0fe" d="M4.1326 18.0548c-1.6417 0 -2.9726 1.331 -2.9726 2.9726C1.16 22.6691 2.4909 24 4.1326 24s2.9726 -1.3309 2.9726 -2.9726s-1.331 -2.9726 -2.9726 -2.9726z"/><path fill="#b6dcfe" d="M12.0054 9.045c-0.0171 0 -0.0342 0 -0.0513 0.0003c-1.6495 0.0904 -2.9293 1.474 -2.891 3.1256v7.9846c0 2.167 0.9535 3.4825 2.3505 3.763c1.6118 0.3266 3.1832 -0.7152 3.5098 -2.327c0.04 -0.1974 0.06 -0.3983 0.0593 -0.5998v-8.9585c0.003 -1.6474 -1.33 -2.9852 -2.9773 -2.9882z"/></g></svg>

After

Width:  |  Height:  |  Size: 1003 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M12.003 0a3 3 0 0 0-2.121 5.121l6.865 6.865-4.446 4.541 1.745 1.836a3.432 3.432 0 0 1 .7.739l.012.011-.001.002a3.432 3.432 0 0 1 .609 1.953 3.432 3.432 0 0 1-.09.78l7.75-7.647c.031-.029.067-.05.098-.08.023-.023.038-.052.06-.076a2.994 2.994 0 0 0-.06-4.166l-9-9A2.99 2.99 0 0 0 12.003 0zM8.63 2.133L.88 9.809a2.998 2.998 0 0 0 0 4.238l7.7 7.75a3.432 3.432 0 0 1-.077-.729 3.432 3.432 0 0 1 3.431-3.431 3.432 3.432 0 0 1 .826.101l-5.523-5.81 4.371-4.373-2.08-2.08c-.903-.904-1.193-2.183-.898-3.342zm3.304 16.004a2.932 2.932 0 0 0-2.931 2.931A2.932 2.932 0 0 0 11.934 24a2.932 2.932 0 0 0 2.932-2.932 2.932 2.932 0 0 0-2.932-2.931z"/></g></svg>

After

Width:  |  Height:  |  Size: 880 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#98ccfd" d="M6.664 15.37a3.336 3.336 0 0 1 -3.332 3.332C1.495 18.702 0 17.208 0 15.37s1.495 -3.333 3.332 -3.333a3.338 3.338 0 0 1 3.332 3.333z"/><path fill="#dff0fe" d="M18.229 11.726a3.658 3.658 0 0 1 -1.987 0.591a3.642 3.642 0 0 1 -1.872 -0.529l0.008 0.012a3.728 3.728 0 0 1 -1.235 -1.19l-2.612 -3.693a0.17 0.17 0 0 1 -0.027 -0.033A3.312 3.312 0 0 0 7.67 5.298a3.318 3.318 0 0 0 -2.848 1.586a0.146 0.146 0 0 1 -0.021 0.028l-3.428 5.343a3.663 3.663 0 0 1 5.094 1.18a0.13 0.13 0 0 1 0.015 0.018l2.756 3.869a3.305 3.305 0 0 0 2.699 1.38a3.31 3.31 0 0 0 2.711 -1.379l0.009 -0.013c0.073 -0.103 0.137 -0.202 0.195 -0.305l1.442 -2.255l1.935 -3.024z"/><path fill="#b6dcfe" d="M23.504 13.628l-0.014 -0.028l-0.044 -0.066a1.109 1.109 0 0 0 -0.029 -0.044l-3.525 -5.37c0.024 0.168 0.052 0.335 0.052 0.51c0 0.741 -0.219 1.457 -0.634 2.068l-2.803 4.38l1.416 2.179l-0.002 0.002a0.131 0.131 0 0 1 0.024 0.028a3.338 3.338 0 0 0 2.723 1.415A3.335 3.335 0 0 0 24 15.37c0 -0.613 -0.171 -1.216 -0.496 -1.742z"/><path fill="#98ccfd" d="M16.242 11.962a3.336 3.336 0 0 0 3.332 -3.333a3.336 3.336 0 0 0 -3.332 -3.332a3.336 3.336 0 0 0 -3.332 3.332a3.338 3.338 0 0 0 3.332 3.333z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M12.1835.0017c-.6378-.0097-1.2884.022-1.7246.0996C8.601.424 7.035 1.2116 5.7384 2.4782 4.406 3.7806 3.582 5.299 3.1818 7.1929l-.1387.6445c-.0118 5.3872-.0233 10.7744-.035 16.1617.2914.0081.591-.0392.8416-.0606 2.348-.2868 4.3442-1.7083 5.4315-3.8651.2749-.5497.472-1.182.6094-1.9707.1135-.6691.1195-.8915.1016-4.3807l-.0176-3.6737.1425-.3574c.1972-.49.7425-1.0352 1.2324-1.2324l.3574-.1426 3.3457-.0058c1.8401 0 3.4545-.025 3.58-.0489.5854-.1135 1.2118-.6027 1.4628-1.1464.0717-.1494.1671-.4415.209-.6387.0657-.3286.0604-.4186-.0352-.789-.2987-1.0993-1.3503-2.6234-2.4257-3.5136C16.6247 1.1638 15.2798.4887 13.828.1482c-.3824-.0866-1.0067-.1368-1.6445-.1465zm8.5369 6.8006c-.0506.1798-.098.3662-.172.5215-.3358.7278-1.0382 1.2776-1.8221 1.4296-3.6737.0566-2.5392.0561-3.6737.0566l-3.248.0059-.2695.1074c-.3135.1262-.827.6397-.9531.9531l-.1074.2676.0175 3.576c.0149 2.8888.007 3.5821-.0605 4.125a8.9918 8.9918 0 0 0 1.5683.1386c4.9662.0001 8.992-4.0258 8.992-8.992a8.9918 8.9918 0 0 0-.2715-2.1893Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(0.6245,0.0349) scale(0.0393)" stroke="#4788c7" stroke-width="25.4444" stroke-linejoin="round" stroke-linecap="round"><path fill="#b6dcfe" d=" M 465.82 50.43 C 520.28 50.39 574.79 59.01 626.44 76.32 C 701.78 101.38 770.88 144.78 826.33 201.59 C 876.12 252.44 914.90 314.06 938.93 381.07 C 923.09 386.59 907.23 392.06 891.39 397.59 C 809.33 426.15 727.21 454.56 645.13 483.06 C 628.47 488.74 611.92 494.79 595.18 500.24 C 594.68 500.37 593.68 500.65 593.19 500.79 C 582.44 470.44 560.05 444.52 531.89 429.01 C 511.78 417.82 488.77 412.08 465.78 412.12 C 465.83 291.56 465.78 171.00 465.82 50.43 Z"/><path fill="#dff0fe" d=" M 366.51 147.29 C 398.94 139.30 432.32 135.38 465.71 135.25 C 465.66 227.55 465.75 319.86 465.66 412.16 C 431.63 412.16 397.85 425.73 373.25 449.25 C 347.16 473.77 331.64 509.19 331.73 545.04 C 331.34 580.97 346.60 616.61 372.61 641.35 C 395.66 663.68 426.93 677.26 458.98 678.87 C 482.48 680.13 506.37 675.32 527.34 664.56 C 527.66 664.50 528.31 664.38 528.64 664.33 C 563.01 731.05 597.51 797.72 631.93 864.43 C 640.65 881.34 649.40 898.25 658.12 915.16 C 608.57 940.80 553.79 956.21 498.16 960.31 C 427.20 965.68 354.86 952.86 290.35 922.73 C 215.08 887.90 150.73 829.96 108.34 758.67 C 87.13 723.17 71.34 684.44 61.73 644.22 C 48.82 590.23 47.02 533.63 56.35 478.92 C 69.10 403.16 103.98 331.41 155.26 274.25 C 210.75 211.94 285.41 166.97 366.51 147.29 Z"/><path fill="#98ccfd" d=" M 645.13 483.06 C 727.21 454.56 809.33 426.15 891.39 397.59 C 917.56 469.06 924.05 547.43 911.59 622.44 C 881.18 617.16 850.77 611.90 820.36 606.62 C 746.60 593.82 672.85 581.03 599.10 568.20 C 602.88 545.92 601.08 522.62 593.37 501.36 C 593.82 501.08 594.72 500.52 595.18 500.24 C 611.92 494.79 628.47 488.74 645.13 483.06 Z"/><path fill="#b6dcfe" d=" M 599.10 568.20 C 672.85 581.03 746.60 593.82 820.36 606.62 C 811.66 659.63 790.48 710.49 759.22 754.15 C 726.33 800.31 682.29 838.44 631.93 864.43 C 597.51 797.72 563.01 731.05 528.64 664.33 C 528.31 664.38 527.66 664.50 527.34 664.56 C 564.66 646.15 592.27 609.30 599.10 568.20 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><g transform="translate(2,2) scale(1.5)" stroke="#4788c7" stroke-width="0.6667" stroke-linejoin="round" stroke-linecap="round"><path fill="#dff0fe" d="M2.203 8.611H.857a.845.845 0 0 0-.841.841v.858a13.31 13.31 0 0 0-.016.6c0 6.627 5.373 12 12 12 6.527 0 11.837-5.212 11.996-11.701 0-.025.004-.05.004-.075V9.452a.845.845 0 0 0-.841-.841h-1.346c-1.159-4.329-5.112-7.521-9.805-7.521-4.692 0-8.645 3.192-9.805 7.521Zm18.444 0H3.37c1.127-3.702 4.57-6.399 8.638-6.399 4.069 0 7.512 2.697 8.639 6.399Z"/></g></svg>

After

Width:  |  Height:  |  Size: 595 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40px" height="40px"><rect x="2.5" y="27.25" width="8.5" height="10.25" fill="#dff0fe" stroke="#4788c7" stroke-linejoin="round"/><rect x="11" y="27.25" width="1.75" height="10.25" fill="#98ccfd" stroke="#4788c7" stroke-linejoin="round"/><rect x="13.75" y="20.5" width="8.5" height="17" fill="#dff0fe" stroke="#4788c7" stroke-linejoin="round"/><rect x="22.25" y="20.5" width="1.75" height="17" fill="#98ccfd" stroke="#4788c7" stroke-linejoin="round"/><rect x="25" y="13.75" width="8.5" height="23.75" fill="#dff0fe" stroke="#4788c7" stroke-linejoin="round"/><rect x="33.5" y="13.75" width="1.75" height="23.75" fill="#98ccfd" stroke="#4788c7" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 740 B

@ -130,6 +130,15 @@
</w-item-section>
<w-item-section>{{ t('admin.general.title') }}</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/analytics`"
active-class="bg-primary text-white"
v-if="userStore.can(`manage:sites`)">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-bar-chart.svg" />
</w-item-section>
<w-item-section>{{ t('admin.analytics.title') }}</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/approvals`"
active-class="bg-primary text-white">
@ -139,15 +148,6 @@
<w-item-section>{{ t('admin.approval.title') }}</w-item-section>
</w-item>
<template v-if="flagsStore.experimental">
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/analytics`"
active-class="bg-primary text-white"
disabled>
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-bar-chart.svg" />
</w-item-section>
<w-item-section>{{ t('admin.analytics.title') }}</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/comments`"
active-class="bg-primary text-white"
@ -224,16 +224,6 @@
:pulse="!storageHealthy" />
</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/tags`"
active-class="bg-primary text-white"
disabled
v-if="flagsStore.experimental && userStore.can(`manage:sites`)">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-tag.svg" />
</w-item-section>
<w-item-section>{{ t('admin.tags.title') }}</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/theme`"
active-class="bg-primary text-white"

@ -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>

@ -158,7 +158,6 @@
:color="actionColor"
icon="la:chart-area"
:label="t(`admin.analytics.title`)"
:disable="!flagsStore.experimental"
:to="`/_admin/` + adminStore.currentSiteId + `/analytics`" />
</w-card-actions>
</w-card>
@ -375,7 +374,6 @@ import { useDark } from '@/composables/dark'
import { notify } from '@/composables/notify'
import { relativeDate } from '@/helpers/datetime'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
@ -388,7 +386,6 @@ import GroupCreateDialog from '@/components/GroupCreateDialog.vue'
// STORES
const adminStore = useAdminStore()
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
const userStore = useUserStore()

@ -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>

@ -76,6 +76,7 @@ const routes = [
// -> Site
{ path: ':siteid/general', component: () => import('@/pages/AdminGeneral.vue') },
{ path: ':siteid/approvals', component: () => import('@/pages/AdminApprovals.vue') },
{ path: ':siteid/analytics/:id?', component: () => import('@/pages/AdminAnalytics.vue') },
{ path: ':siteid/blocks', component: () => import('@/pages/AdminBlocks.vue') },
{ path: ':siteid/editors', component: () => import('@/pages/AdminEditors.vue') },
{ path: ':siteid/locale', component: () => import('@/pages/AdminLocale.vue') },

Loading…
Cancel
Save