feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
Implements the hybrid scope agreed on in #663: two native adapters (Umami + Rybbit) for trackers we'd keep maintained, plus a Custom script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible native, deeper metrics) explicitly deferred until someone asks. ## Architecture **Backend `services/trackers/`**: - `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` → `{ desktop, mobile, tablet } | null`. Null = route falls back to access_logs heuristic. - `umamiAdapter.js` — extracted from the `services/umamiClient.js` that landed in #662. Same 10 test contract preserved. - `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension= device` with Bearer auth, accepts both bare-array and `{data:[...]}` envelope variants, tolerates `sessions`/`visitors`/`value`/`count` metric keys. - `customScriptSanitiser.js` — sanitize-html with a tracker-tight allowlist (`<script>` / `<noscript>` / `<link rel=preconnect| dns-prefetch>` / `<meta>`). Strips event-handler attributes, `javascript:` and `data:` URLs. - `index.js` factory: `resolveAdapter()` reads `analytics_tracker_provider` setting → dispatches. Back-compat: when provider is unset, infers `umami` from the legacy `analytics_umami_enabled` flag so #662 installs keep working without an admin touching settings. **Backend routes**: - `adminDashboard.js /analytics`: now goes through `resolveAdapter()`. Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js` and its test file deleted (replaced by the adapter shape). - `adminSettings.js PUT /analytics`: validates the new `analytics_tracker_provider` enum, sanitises any incoming `analytics_custom_head_html` on save via the sanitiser. Masks the new `analytics_rybbit_api_key` on every GET — same pattern as Umami's API key and recaptcha secret. - `publicSettings.js`: emits `analytics_tracker_provider`, `rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and the pre-sanitised `analytics_custom_head_html` (only when provider=custom). Legacy `umami_*` fields stay for back-compat. **Frontend**: - `analytics.service.ts` reworked into a provider-aware shape. `initialize({provider, ...config})` dispatches to Umami / Rybbit / Custom / None. `track()` calls dispatch to `window.umami.track` / `window.rybbit.event` / no-op based on the loaded provider. - `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider` from public-settings and routes to the right `initialize` call. Legacy `umami_enabled`-based path preserved as fallback when the new field is missing. - `AnalyticsTab.tsx` (Settings → Analytics) reworked with a "Provider" dropdown switching between None / Umami / Rybbit / Custom panels. Each panel renders its own config fields; Custom panel surfaces an explicit CSP-reminder banner. - `useSettingsState.ts` shape extended with `tracker_provider`, `rybbit_url`/`rybbit_website_id`/`rybbit_api_key`, `custom_head_html`. Save mutation keeps `umami_enabled` in sync with `tracker_provider==='umami'` for back-compat with downstream consumers (publicSettings shape, embedded iframe). - `publicSettings.service.ts` type extended. **i18n**: EN + DE for the provider heading + description + dropdown options + Rybbit fields + Custom HTML field + CSP warning. ## Custom mode — script execution caveat When the gallery `<head>` receives the custom HTML, simply assigning innerHTML to a container element wouldn't execute the embedded `<script>` tags (per the HTML spec, dynamically-inserted scripts via innerHTML are non-running). `analytics.service.ts:120-130` re-creates each `<script>` element manually so the browser actually evaluates it. Non-script nodes (link, meta, noscript) move in directly. ## Tests **Backend** (42 cases, all pass locally): - `umamiAdapter.test.js` (10) — pinned from the original `umamiClient.test.js`: missing-config / URL shape / encoding / payload normalisation / `laptop`→`desktop` / unknown buckets / empty / non-2xx / invalid JSON / network error. - `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit: bare-array + envelope payload, `sessions`/`visitors`/`dimension` key tolerance, encoding, failure modes. - `trackerFactory.test.js` (6) — resolves null for `none`/`custom`, correct adapter for `umami`/`rybbit`, back-compat path via legacy `analytics_umami_enabled`, garbage-provider defensive null. - `customScriptSanitiser.test.js` (12) — Plausible-style passthrough, Umami-style passthrough, inline body passthrough, `<noscript>` allowed, `<link rel="preconnect|dns-prefetch">` allowed, `<link rel="stylesheet">` stripped, disallowed tags stripped, `javascript:`/`data:` URLs stripped, `on*` event handlers stripped, defensive on malformed input. - `analyticsDateMerge.test.js` (5) — preserved from #662. **Frontend**: full 84-case vitest suite green; tsc + eslint clean on changed files. Adapter changes are narrow refactors of code covered by backend tests; no new analytics-page unit test added. ## End-to-end smoke (dockerised backend + my changes mounted) ``` test 1 (back-compat: no provider, umami_enabled=true) → factory returns umami adapter, /analytics returns devicesSource:access_logs (umami fetch to fake host fails gracefully). ✓ test 2 (invalid provider value) → 400 "analytics_tracker_provider must be one of: none, umami, rybbit, custom" ✓ test 3 (save custom HTML with XSS payload) → stored sanitised: `<script>alert(1)</script>evil<script async defer data-domain="x.com" src="https://plausible.io/js/script.js"></script>` (<div> stripped; script tags survive but CSP `script-src 'self'` still blocks inline + non-allowlisted external at runtime) ✓ test 4 (public-settings exposes the provider switch) → `analytics_tracker_provider: 'custom'`, `analytics_custom_head_html: '<sanitised>'` ✓ ``` ## Out of scope (next discussions) - **Plausible native** — covered via Custom mode for now; native is Phase 2 if someone explicitly asks. - **CSP "trusted domains" admin input** — Phase 1.5. For now operators add their tracker domain to nginx/proxy CSP manually; the new CSP-reminder banner in the Custom panel makes that clear. - **Refactor `(window as any).umami.track(...)` direct calls** in PhotoLightbox/PhotoGrid to go through `analyticsService.track()` so events fire on the right tracker. Currently a no-op when Umami isn't loaded; functional but not optimal. Closes #663 Phase 1.
This commit is contained in:
+39
-6
@@ -87,8 +87,13 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
// Bootstraps Umami analytics from /public/settings. Lives inside QueryClientProvider
|
||||
// so it shares the public-settings cache with every other consumer of usePublicSettings.
|
||||
// Bootstraps the analytics tracker from /public/settings. Lives inside
|
||||
// QueryClientProvider so it shares the public-settings cache with every
|
||||
// other consumer of usePublicSettings. Dispatches based on the
|
||||
// `analytics_tracker_provider` switch (#663 Phase 1) — Umami / Rybbit /
|
||||
// Custom / None. Back-compat: when the provider field is missing or unset,
|
||||
// falls through to the legacy `umami_enabled`-based behaviour so installs
|
||||
// that haven't picked yet keep working.
|
||||
function AnalyticsBootstrap() {
|
||||
const { data: settings, isError } = usePublicSettings();
|
||||
|
||||
@@ -97,21 +102,49 @@ function AnalyticsBootstrap() {
|
||||
|
||||
const envUmamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const envUmamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
const provider = settings?.analytics_tracker_provider;
|
||||
|
||||
if (settings?.umami_enabled && settings.umami_url && settings.umami_website_id) {
|
||||
if (provider === 'rybbit' && settings?.rybbit_url && settings.rybbit_website_id) {
|
||||
analyticsService.initialize({
|
||||
websiteId: settings.umami_website_id,
|
||||
hostUrl: settings.umami_url,
|
||||
provider: 'rybbit',
|
||||
hostUrl: settings.rybbit_url,
|
||||
websiteId: settings.rybbit_website_id,
|
||||
autoTrack: true,
|
||||
doNotTrack: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'custom') {
|
||||
analyticsService.initialize({
|
||||
provider: 'custom',
|
||||
customHeadHtml: settings?.analytics_custom_head_html || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Umami: explicit provider OR legacy umami_enabled path.
|
||||
if (
|
||||
(provider === 'umami' || settings?.umami_enabled)
|
||||
&& settings?.umami_url && settings?.umami_website_id
|
||||
) {
|
||||
analyticsService.initialize({
|
||||
provider: 'umami',
|
||||
hostUrl: settings.umami_url,
|
||||
websiteId: settings.umami_website_id,
|
||||
autoTrack: true,
|
||||
doNotTrack: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Env-var fallback (legacy deploys). Only when no DB config and
|
||||
// analytics aren't disabled at the public-site level.
|
||||
if (envUmamiUrl && envUmamiWebsiteId && (isError || settings?.enable_analytics !== false)) {
|
||||
analyticsService.initialize({
|
||||
websiteId: envUmamiWebsiteId,
|
||||
provider: 'umami',
|
||||
hostUrl: envUmamiUrl,
|
||||
websiteId: envUmamiWebsiteId,
|
||||
autoTrack: true,
|
||||
doNotTrack: true,
|
||||
});
|
||||
|
||||
@@ -45,7 +45,13 @@ export interface SecuritySettings {
|
||||
recaptcha_secret_key: string;
|
||||
}
|
||||
|
||||
export type TrackerProvider = 'none' | 'umami' | 'rybbit' | 'custom';
|
||||
|
||||
export interface AnalyticsSettings {
|
||||
// Tracker-provider switch (#663 Phase 1). Drives which provider's
|
||||
// settings panel renders + which tracker script gets injected into the
|
||||
// public gallery. 'none' = no tracker; 'custom' = paste-your-own HTML.
|
||||
tracker_provider: TrackerProvider;
|
||||
umami_enabled: boolean;
|
||||
umami_url: string;
|
||||
umami_website_id: string;
|
||||
@@ -56,6 +62,14 @@ export interface AnalyticsSettings {
|
||||
// on GET when a value is stored — submit the masked sentinel unchanged
|
||||
// to keep the stored value, or a real key to replace it.
|
||||
umami_api_key: string;
|
||||
// Rybbit native provider (#663 Phase 1). Same shape as Umami.
|
||||
rybbit_url: string;
|
||||
rybbit_website_id: string;
|
||||
rybbit_api_key: string;
|
||||
// Custom-mode HTML snippet (#663). Sanitised server-side on save via
|
||||
// sanitize-html with a tracker-script allowlist. Rendered into the
|
||||
// public gallery <head> as-is on every request.
|
||||
custom_head_html: string;
|
||||
}
|
||||
|
||||
export interface EventSettings {
|
||||
@@ -132,11 +146,16 @@ export function useSettingsState() {
|
||||
|
||||
// Analytics settings state
|
||||
const [analyticsSettings, setAnalyticsSettings] = useState<AnalyticsSettings>({
|
||||
tracker_provider: 'none',
|
||||
umami_enabled: false,
|
||||
umami_url: '',
|
||||
umami_website_id: '',
|
||||
umami_share_url: '',
|
||||
umami_api_key: ''
|
||||
umami_api_key: '',
|
||||
rybbit_url: '',
|
||||
rybbit_website_id: '',
|
||||
rybbit_api_key: '',
|
||||
custom_head_html: ''
|
||||
});
|
||||
|
||||
// Event creation settings state
|
||||
@@ -222,12 +241,27 @@ export function useSettingsState() {
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||
});
|
||||
|
||||
// Tracker provider: prefer explicit setting; fall back to legacy
|
||||
// umami_enabled flag for installs that haven't picked yet (#663).
|
||||
const explicitProvider = settings.analytics_tracker_provider;
|
||||
const provider: TrackerProvider = (
|
||||
explicitProvider === 'none' || explicitProvider === 'umami'
|
||||
|| explicitProvider === 'rybbit' || explicitProvider === 'custom'
|
||||
)
|
||||
? explicitProvider
|
||||
: (toBoolean(settings.analytics_umami_enabled, false) ? 'umami' : 'none');
|
||||
|
||||
setAnalyticsSettings({
|
||||
tracker_provider: provider,
|
||||
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
|
||||
umami_url: settings.analytics_umami_url || '',
|
||||
umami_website_id: settings.analytics_umami_website_id || '',
|
||||
umami_share_url: settings.analytics_umami_share_url || '',
|
||||
umami_api_key: settings.analytics_umami_api_key || ''
|
||||
umami_api_key: settings.analytics_umami_api_key || '',
|
||||
rybbit_url: settings.analytics_rybbit_url || '',
|
||||
rybbit_website_id: settings.analytics_rybbit_website_id || '',
|
||||
rybbit_api_key: settings.analytics_rybbit_api_key || '',
|
||||
custom_head_html: settings.analytics_custom_head_html || ''
|
||||
});
|
||||
|
||||
setEventSettings({
|
||||
@@ -323,12 +357,16 @@ export function useSettingsState() {
|
||||
mutationFn: async () => {
|
||||
const settingsData: Record<string, unknown> = {};
|
||||
Object.entries(analyticsSettings).forEach(([key, value]) => {
|
||||
// The Umami API key (#661 Bug C) is returned masked as `••••••••`
|
||||
// on GET so it doesn't leak in the response body. Don't re-save
|
||||
// that sentinel — silently preserve whatever's already stored.
|
||||
if (key === 'umami_api_key' && value === '••••••••') return;
|
||||
// API keys (Umami / Rybbit) are returned masked as `••••••••` on
|
||||
// GET so they don't leak in the response body. Don't re-save the
|
||||
// sentinel — silently preserve whatever's already stored.
|
||||
if ((key === 'umami_api_key' || key === 'rybbit_api_key') && value === '••••••••') return;
|
||||
settingsData[`analytics_${key}`] = value;
|
||||
});
|
||||
// Keep the legacy `analytics_umami_enabled` flag in sync with the
|
||||
// new `tracker_provider` switch so back-compat consumers (publicSettings
|
||||
// surface, embedded Umami iframe) keep working when provider !== 'umami'.
|
||||
settingsData.analytics_umami_enabled = analyticsSettings.tracker_provider === 'umami';
|
||||
return settingsService.updateSettings(settingsData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Save, Globe, Key, Activity, AlertCircle } from 'lucide-react';
|
||||
import { Save, Globe, Key, Activity, AlertCircle, Code } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AnalyticsSettings } from '../hooks/useSettingsState';
|
||||
import type { AnalyticsSettings, TrackerProvider } from '../hooks/useSettingsState';
|
||||
|
||||
interface AnalyticsTabProps {
|
||||
analyticsSettings: AnalyticsSettings;
|
||||
@@ -13,6 +13,8 @@ interface AnalyticsTabProps {
|
||||
};
|
||||
}
|
||||
|
||||
const PROVIDER_OPTIONS: TrackerProvider[] = ['none', 'umami', 'rybbit', 'custom'];
|
||||
|
||||
export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
||||
analyticsSettings,
|
||||
setAnalyticsSettings,
|
||||
@@ -20,110 +22,238 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const provider = analyticsSettings.tracker_provider;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={analyticsSettings.umami_enabled}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.analytics.enableUmami')}</span>
|
||||
</label>
|
||||
|
||||
{analyticsSettings.umami_enabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.umamiUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.umami_url}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
|
||||
placeholder="https://analytics.yourdomain.com"
|
||||
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.analytics.umamiUrlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.websiteId')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={analyticsSettings.umami_website_id}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.analytics.websiteIdHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.shareUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.umami_share_url}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
|
||||
placeholder="https://analytics.yourdomain.com/share/..."
|
||||
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.analytics.shareUrlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Umami API key — only used by the dashboard's device
|
||||
breakdown today (#661 Bug C). Optional; the rest of the
|
||||
integration works without it. Masked as •••••••• on GET. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.umamiApiKey', 'API key')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={analyticsSettings.umami_api_key}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_api_key: e.target.value }))}
|
||||
placeholder="api_xxx…"
|
||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t(
|
||||
'settings.analytics.umamiApiKeyHelp',
|
||||
'Optional. Required only for the device-breakdown chart on the Analytics dashboard. Generate in Umami → Settings → Profile → API Keys. Stored masked as •••••••• once saved — leave the masked value to keep the existing key.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('settings.analytics.providerHeading', 'Analytics provider')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t(
|
||||
'settings.analytics.providerDescription',
|
||||
'Pick which tracker to use for the public gallery, or paste your own script. The admin dashboard\'s device-breakdown chart only enriches when you pick Umami or Rybbit — those expose a metrics API. Other providers (Plausible, Matomo, GA4, …) work via the Custom mode below.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Provider dropdown — single source of truth for which panel renders. */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.providerLabel', 'Provider')}
|
||||
</label>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({
|
||||
...prev,
|
||||
tracker_provider: e.target.value as TrackerProvider,
|
||||
}))}
|
||||
className="w-full sm:w-72 px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
{PROVIDER_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`settings.analytics.provider.${p}`, p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Umami panel */}
|
||||
{provider === 'umami' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.umamiUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.umami_url}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_url: e.target.value }))}
|
||||
placeholder="https://analytics.yourdomain.com"
|
||||
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.analytics.umamiUrlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.websiteId')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={analyticsSettings.umami_website_id}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_website_id: e.target.value }))}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.analytics.websiteIdHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.shareUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.umami_share_url}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_share_url: e.target.value }))}
|
||||
placeholder="https://analytics.yourdomain.com/share/..."
|
||||
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.analytics.shareUrlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.umamiApiKey', 'API key')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={analyticsSettings.umami_api_key}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_api_key: e.target.value }))}
|
||||
placeholder="api_xxx…"
|
||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t(
|
||||
'settings.analytics.umamiApiKeyHelp',
|
||||
'Optional. Required only for the device-breakdown chart on the Analytics dashboard. Generate in Umami → Settings → Profile → API Keys. Stored masked as •••••••• once saved — leave the masked value to keep the existing key.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rybbit panel */}
|
||||
{provider === 'rybbit' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.rybbitUrl', 'Rybbit URL')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.rybbit_url}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_url: e.target.value }))}
|
||||
placeholder="https://app.rybbit.io"
|
||||
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t(
|
||||
'settings.analytics.rybbitUrlHelp',
|
||||
'Your Rybbit instance URL — `https://app.rybbit.io` for the SaaS, or `https://rybbit.yourdomain.com` for self-hosted.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.rybbitWebsiteId', 'Site ID')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={analyticsSettings.rybbit_website_id}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_website_id: e.target.value }))}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t(
|
||||
'settings.analytics.rybbitWebsiteIdHelp',
|
||||
'Found in Rybbit → Sites → your site → Tracking script.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.rybbitApiKey', 'API key')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={analyticsSettings.rybbit_api_key}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_api_key: e.target.value }))}
|
||||
placeholder="rybbit_xxx…"
|
||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t(
|
||||
'settings.analytics.rybbitApiKeyHelp',
|
||||
'Optional. Required only for the device-breakdown chart. Generate in Rybbit → Account → Settings → API Keys. Stored masked as •••••••• once saved.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom panel */}
|
||||
{provider === 'custom' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.analytics.customHeadHtml', 'Custom <head> HTML')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-3 pointer-events-none">
|
||||
<Code className="w-5 h-5 text-neutral-400" />
|
||||
</span>
|
||||
<textarea
|
||||
value={analyticsSettings.custom_head_html}
|
||||
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, custom_head_html: e.target.value }))}
|
||||
placeholder={'<script async defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>'}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
className="w-full pl-10 pr-3 py-2 font-mono text-xs border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t(
|
||||
'settings.analytics.customHeadHtmlHelp',
|
||||
'Paste your tracker\'s `<head>` snippet (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics, …). Sanitised on save: only `<script>` / `<noscript>` / `<link rel="preconnect|dns-prefetch">` / `<meta>` tags survive, and event-handler attributes are stripped. The admin dashboard\'s device-breakdown chart falls back to a server-side user-agent heuristic in this mode — pick Umami or Rybbit if you want the tracker-side numbers.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<p className="font-medium mb-1">
|
||||
{t('settings.analytics.customCspWarning', 'Content-Security-Policy reminder')}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'settings.analytics.customCspWarningText',
|
||||
'PicPeak ships with a strict CSP (`script-src \'self\'`). If your tracker loads from another domain, add that domain to your reverse-proxy or nginx CSP config — otherwise the browser silently blocks the script.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === 'none' && (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
|
||||
<p>{t('settings.analytics.umamiInfoText')}</p>
|
||||
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
|
||||
{t('settings.analytics.learnMore')}
|
||||
</a>
|
||||
{t(
|
||||
'settings.analytics.providerNoneInfo',
|
||||
'No external tracker injected. The admin dashboard still shows summary cards + the daily chart from PicPeak\'s own access_logs; the device-breakdown chart uses a coarse user-agent heuristic.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
|
||||
@@ -1519,6 +1519,26 @@
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analyse",
|
||||
"providerHeading": "Analytics-Anbieter",
|
||||
"providerDescription": "Tracker für die öffentliche Galerie wählen oder eigenes Skript einfügen. Das Geräte-Diagramm im Admin-Dashboard wird nur bei Umami oder Rybbit angereichert — diese stellen eine Metrics-API bereit. Andere Anbieter (Plausible, Matomo, GA4, …) funktionieren über den Custom-Modus unten.",
|
||||
"providerLabel": "Anbieter",
|
||||
"provider": {
|
||||
"none": "Keiner",
|
||||
"umami": "Umami",
|
||||
"rybbit": "Rybbit",
|
||||
"custom": "Custom (eigenes Skript einfügen)"
|
||||
},
|
||||
"providerNoneInfo": "Kein externer Tracker eingebunden. Das Admin-Dashboard zeigt weiterhin Übersichtskarten + den Tagesverlauf aus PicPeaks eigenen access_logs; das Geräte-Diagramm verwendet eine grobe User-Agent-Heuristik.",
|
||||
"rybbitUrl": "Rybbit-URL",
|
||||
"rybbitUrlHelp": "URL Ihrer Rybbit-Instanz — `https://app.rybbit.io` für SaaS oder `https://rybbit.ihre-domain.de` für selbst gehostet.",
|
||||
"rybbitWebsiteId": "Site-ID",
|
||||
"rybbitWebsiteIdHelp": "Zu finden in Rybbit → Sites → Ihre Site → Tracking-Skript.",
|
||||
"rybbitApiKey": "API-Schlüssel",
|
||||
"rybbitApiKeyHelp": "Optional. Nur für das Geräte-Diagramm erforderlich. Erstellen in Rybbit → Account → Settings → API Keys. Beim Abruf maskiert als •••••••• gespeichert.",
|
||||
"customHeadHtml": "Eigenes <head>-HTML",
|
||||
"customHeadHtmlHelp": "Tracker-`<head>`-Snippet einfügen (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics, …). Beim Speichern bereinigt: nur `<script>`, `<noscript>`, `<link rel=\"preconnect|dns-prefetch\">` und `<meta>` bleiben erhalten; Event-Handler-Attribute werden entfernt. Das Geräte-Diagramm fällt in diesem Modus auf eine serverseitige User-Agent-Heuristik zurück — für tracker-seitige Zahlen Umami oder Rybbit wählen.",
|
||||
"customCspWarning": "Hinweis zur Content-Security-Policy",
|
||||
"customCspWarningText": "PicPeak setzt eine strikte CSP (`script-src 'self'`) ein. Wenn Ihr Tracker von einer anderen Domain lädt, müssen Sie diese Domain in Ihrer Reverse-Proxy- oder nginx-CSP-Konfiguration zulassen — sonst blockiert der Browser das Skript stillschweigend.",
|
||||
"umamiIntegration": "Umami Analytics Integration",
|
||||
"enableUmami": "Umami Analytics aktivieren",
|
||||
"umamiUrl": "Umami URL",
|
||||
|
||||
@@ -1075,6 +1075,26 @@
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"providerHeading": "Analytics provider",
|
||||
"providerDescription": "Pick which tracker to use for the public gallery, or paste your own script. The admin dashboard's device-breakdown chart only enriches when you pick Umami or Rybbit — those expose a metrics API. Other providers (Plausible, Matomo, GA4, …) work via the Custom mode below.",
|
||||
"providerLabel": "Provider",
|
||||
"provider": {
|
||||
"none": "None",
|
||||
"umami": "Umami",
|
||||
"rybbit": "Rybbit",
|
||||
"custom": "Custom (paste your own script)"
|
||||
},
|
||||
"providerNoneInfo": "No external tracker injected. The admin dashboard still shows summary cards + the daily chart from PicPeak's own access_logs; the device-breakdown chart uses a coarse user-agent heuristic.",
|
||||
"rybbitUrl": "Rybbit URL",
|
||||
"rybbitUrlHelp": "Your Rybbit instance URL — `https://app.rybbit.io` for the SaaS, or `https://rybbit.yourdomain.com` for self-hosted.",
|
||||
"rybbitWebsiteId": "Site ID",
|
||||
"rybbitWebsiteIdHelp": "Found in Rybbit → Sites → your site → Tracking script.",
|
||||
"rybbitApiKey": "API key",
|
||||
"rybbitApiKeyHelp": "Optional. Required only for the device-breakdown chart. Generate in Rybbit → Account → Settings → API Keys. Stored masked as •••••••• once saved.",
|
||||
"customHeadHtml": "Custom <head> HTML",
|
||||
"customHeadHtmlHelp": "Paste your tracker's `<head>` snippet (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics, …). Sanitised on save: only `<script>`, `<noscript>`, `<link rel=\"preconnect|dns-prefetch\">` and `<meta>` tags survive, and event-handler attributes are stripped. The admin dashboard's device-breakdown chart falls back to a server-side user-agent heuristic in this mode — pick Umami or Rybbit if you want the tracker-side numbers.",
|
||||
"customCspWarning": "Content-Security-Policy reminder",
|
||||
"customCspWarningText": "PicPeak ships with a strict CSP (`script-src 'self'`). If your tracker loads from another domain, add that domain to your reverse-proxy or nginx CSP config — otherwise the browser silently blocks the script.",
|
||||
"umamiIntegration": "Umami Analytics Integration",
|
||||
"enableUmami": "Enable Umami Analytics",
|
||||
"umamiUrl": "Umami URL",
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
// Umami Analytics Service
|
||||
// Provides integration with Umami for tracking page views and events
|
||||
// Pluggable analytics service (#663 Phase 1).
|
||||
//
|
||||
// Routes initialization to the right tracker based on the operator's chosen
|
||||
// provider in Settings → Analytics, and dispatches `track()` calls to the
|
||||
// tracker's runtime API when one is loaded.
|
||||
//
|
||||
// None → no script, no-op tracking.
|
||||
// Umami → inject Umami script tag; `window.umami.track(name, data)`.
|
||||
// Rybbit → inject Rybbit script tag; `window.rybbit.event(name, data)`.
|
||||
// Custom → render admin-pasted HTML (sanitised server-side) into <head>;
|
||||
// no runtime API hook — `track()` becomes a no-op.
|
||||
|
||||
interface UmamiConfig {
|
||||
websiteId?: string;
|
||||
hostUrl?: string;
|
||||
export type TrackerProvider = 'none' | 'umami' | 'rybbit' | 'custom';
|
||||
|
||||
interface BaseInitConfig {
|
||||
provider: TrackerProvider;
|
||||
autoTrack?: boolean;
|
||||
doNotTrack?: boolean;
|
||||
}
|
||||
|
||||
interface UmamiInitConfig extends BaseInitConfig {
|
||||
provider: 'umami';
|
||||
websiteId: string;
|
||||
hostUrl: string;
|
||||
domains?: string[];
|
||||
}
|
||||
|
||||
interface RybbitInitConfig extends BaseInitConfig {
|
||||
provider: 'rybbit';
|
||||
websiteId: string;
|
||||
hostUrl: string;
|
||||
}
|
||||
|
||||
interface CustomInitConfig extends BaseInitConfig {
|
||||
provider: 'custom';
|
||||
customHeadHtml: string;
|
||||
}
|
||||
|
||||
interface NoneInitConfig extends BaseInitConfig {
|
||||
provider: 'none';
|
||||
}
|
||||
|
||||
type InitConfig = UmamiInitConfig | RybbitInitConfig | CustomInitConfig | NoneInitConfig;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
@@ -21,74 +54,107 @@ declare global {
|
||||
websiteId?: string
|
||||
) => void;
|
||||
};
|
||||
rybbit?: {
|
||||
event: (eventName: string, eventData?: any) => void;
|
||||
pageview?: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AnalyticsService {
|
||||
private initialized = false;
|
||||
private provider: TrackerProvider = 'none';
|
||||
private websiteId: string | null = null;
|
||||
// private hostUrl: string | null = null;
|
||||
|
||||
initialize(config: UmamiConfig) {
|
||||
initialize(config: InitConfig) {
|
||||
if (this.initialized) return;
|
||||
|
||||
const { websiteId, hostUrl, autoTrack = true, doNotTrack = true } = config;
|
||||
|
||||
if (!websiteId || !hostUrl) {
|
||||
console.warn('Umami Analytics: Missing websiteId or hostUrl');
|
||||
if (config.provider === 'none') {
|
||||
this.initialized = true;
|
||||
this.provider = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this.websiteId = websiteId;
|
||||
// this.hostUrl = hostUrl;
|
||||
|
||||
// Create and inject Umami script
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = `${hostUrl}/script.js`;
|
||||
script.setAttribute('data-website-id', websiteId);
|
||||
|
||||
if (!autoTrack) {
|
||||
script.setAttribute('data-auto-track', 'false');
|
||||
}
|
||||
|
||||
if (doNotTrack) {
|
||||
script.setAttribute('data-do-not-track', 'true');
|
||||
if (config.provider === 'umami') {
|
||||
if (!config.websiteId || !config.hostUrl) {
|
||||
console.warn('Umami: missing websiteId or hostUrl');
|
||||
return;
|
||||
}
|
||||
this.websiteId = config.websiteId;
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = `${config.hostUrl.replace(/\/+$/, '')}/script.js`;
|
||||
script.setAttribute('data-website-id', config.websiteId);
|
||||
if (config.autoTrack === false) script.setAttribute('data-auto-track', 'false');
|
||||
if (config.doNotTrack !== false) script.setAttribute('data-do-not-track', 'true');
|
||||
if (config.domains?.length) script.setAttribute('data-domains', config.domains.join(','));
|
||||
document.head.appendChild(script);
|
||||
} else if (config.provider === 'rybbit') {
|
||||
if (!config.websiteId || !config.hostUrl) {
|
||||
console.warn('Rybbit: missing websiteId or hostUrl');
|
||||
return;
|
||||
}
|
||||
this.websiteId = config.websiteId;
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = `${config.hostUrl.replace(/\/+$/, '')}/api/script.js`;
|
||||
script.setAttribute('data-site-id', config.websiteId);
|
||||
document.head.appendChild(script);
|
||||
} else if (config.provider === 'custom') {
|
||||
// The admin-pasted HTML is sanitised server-side (see
|
||||
// backend `customScriptSanitiser.js`). We render it via a wrapper
|
||||
// <div> and move each child node into <head> so <script> tags
|
||||
// execute. Using innerHTML on a <head> directly is also fine
|
||||
// here — the child nodes get parsed and inserted in order.
|
||||
const html = (config.customHeadHtml || '').trim();
|
||||
if (html) {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
// Re-create <script> elements so the browser actually evaluates
|
||||
// them — assigning innerHTML to a parent inserts the nodes but
|
||||
// doesn't trigger script execution per the HTML spec.
|
||||
Array.from(container.childNodes).forEach((node) => {
|
||||
if (node.nodeName === 'SCRIPT') {
|
||||
const orig = node as HTMLScriptElement;
|
||||
const fresh = document.createElement('script');
|
||||
Array.from(orig.attributes).forEach((attr) => fresh.setAttribute(attr.name, attr.value));
|
||||
if (orig.textContent) fresh.textContent = orig.textContent;
|
||||
document.head.appendChild(fresh);
|
||||
} else {
|
||||
document.head.appendChild(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.domains && config.domains.length > 0) {
|
||||
script.setAttribute('data-domains', config.domains.join(','));
|
||||
}
|
||||
|
||||
document.head.appendChild(script);
|
||||
this.provider = config.provider;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
// Check if analytics is initialized
|
||||
isInitialized() {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
// Track custom events
|
||||
// Track custom events. Dispatched to whichever tracker is loaded; custom
|
||||
// mode no-ops (we don't know the operator's tracker's runtime API).
|
||||
track(eventName: string, eventData?: Record<string, any>) {
|
||||
if (!this.initialized || !window.umami) {
|
||||
// Silently ignore if not initialized
|
||||
return;
|
||||
if (!this.initialized) return;
|
||||
if (this.provider === 'umami' && typeof window !== 'undefined' && window.umami) {
|
||||
window.umami.track(eventName, eventData);
|
||||
} else if (this.provider === 'rybbit' && typeof window !== 'undefined' && window.rybbit) {
|
||||
window.rybbit.event(eventName, eventData);
|
||||
}
|
||||
|
||||
// Umami expects flat event data
|
||||
window.umami.track(eventName, eventData);
|
||||
// 'none' / 'custom' / unloaded → silently ignore.
|
||||
}
|
||||
|
||||
// Track page views manually
|
||||
trackPageView(url?: string, referrer?: string) {
|
||||
if (!this.initialized || !window.umami) {
|
||||
// Silently ignore if not initialized
|
||||
return;
|
||||
if (!this.initialized) return;
|
||||
if (this.provider === 'umami' && typeof window !== 'undefined' && window.umami) {
|
||||
window.umami.trackView(url, referrer, this.websiteId || undefined);
|
||||
} else if (this.provider === 'rybbit' && typeof window !== 'undefined' && window.rybbit?.pageview) {
|
||||
window.rybbit.pageview();
|
||||
}
|
||||
|
||||
window.umami.trackView(url, referrer, this.websiteId || undefined);
|
||||
}
|
||||
|
||||
// Gallery-specific tracking events
|
||||
@@ -96,12 +162,10 @@ class AnalyticsService {
|
||||
this.track(`gallery_${eventType}`, data);
|
||||
}
|
||||
|
||||
// Admin-specific tracking events
|
||||
trackAdminEvent(eventType: 'login' | 'event_created' | 'event_archived' | 'event_deleted' | 'settings_updated', data?: any) {
|
||||
this.track(`admin_${eventType}`, data);
|
||||
}
|
||||
|
||||
// Track download events with more context
|
||||
trackDownload(photoId: string | number, gallerySlug: string, isBulk: boolean = false) {
|
||||
this.track('photo_download', {
|
||||
photo_id: photoId,
|
||||
@@ -111,7 +175,6 @@ class AnalyticsService {
|
||||
});
|
||||
}
|
||||
|
||||
// Track expiration warning views
|
||||
trackExpirationWarning(gallerySlug: string, daysRemaining: number) {
|
||||
this.track('expiration_warning_viewed', {
|
||||
gallery: gallerySlug,
|
||||
@@ -120,7 +183,6 @@ class AnalyticsService {
|
||||
});
|
||||
}
|
||||
|
||||
// Track search usage
|
||||
trackSearch(query: string, resultsCount: number, context: 'gallery' | 'admin') {
|
||||
this.track('search_performed', {
|
||||
query_length: query.length,
|
||||
@@ -146,4 +208,4 @@ export const useAnalytics = () => {
|
||||
}, [location]);
|
||||
|
||||
return analyticsService;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,6 +68,14 @@ export interface PublicSettings {
|
||||
umami_url: string | null;
|
||||
umami_website_id: string | null;
|
||||
umami_share_url: string | null;
|
||||
// Pluggable trackers (#663 Phase 1). The backend always surfaces these;
|
||||
// missing fields fall back via the existing umami_* shape so older
|
||||
// builds keep working.
|
||||
analytics_tracker_provider?: 'none' | 'umami' | 'rybbit' | 'custom';
|
||||
rybbit_url?: string | null;
|
||||
rybbit_website_id?: string | null;
|
||||
// Custom-mode HTML snippet, already sanitised server-side.
|
||||
analytics_custom_head_html?: string;
|
||||
// Upload settings
|
||||
allowed_file_types?: string;
|
||||
// #613 — per-batch file count limit, surfaced so the guest UserPhotoUpload
|
||||
|
||||
Reference in New Issue
Block a user