feat(phase3): PWA, auto-discovery, bookmarklet, multi-tab sync

- PWA: manifest, service worker (cache-first static, network-first API),
  offline page, install prompt banner
- Auto-discovery: Docker socket + Traefik API scanning, approval UI
- Quick-add bookmarklet: popup-based add page, favicon auto-detect
- Multi-tab sync: BroadcastChannel for theme + data changes
- i18n translations for all new strings (EN/RU)
This commit is contained in:
2026-03-25 00:59:19 +03:00
parent c6a7de895d
commit dd6958b4d6
28 changed files with 1712 additions and 266 deletions
+14
View File
@@ -10,6 +10,9 @@
import { ui } from '$lib/stores/ui.svelte';
import { search } from '$lib/stores/search.svelte';
import { locale as i18nLocale } from 'svelte-i18n';
import { onSyncMessage } from '$lib/utils/broadcastSync.js';
import { invalidateAll } from '$app/navigation';
import { onDestroy } from 'svelte';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
@@ -26,6 +29,17 @@
ui.initEffects();
search.initEffects();
// Listen for cross-tab sync messages (theme changes & data invalidation)
const cleanupSync = onSyncMessage((msg) => {
if (msg.type === 'theme-change') {
theme.applyFromBroadcast(msg.payload);
} else if (msg.type === 'data-change') {
invalidateAll();
}
});
onDestroy(cleanupSync);
// Pages that should NOT have the main layout (login, register)
const noLayoutPaths = ['/login', '/register'];
const showLayout = $derived(!noLayoutPaths.includes($page.url.pathname));
+8 -1
View File
@@ -34,7 +34,14 @@ export const load: PageServerLoad = async (event) => {
zod(updateSystemSettingsSchema)
);
return { settings, form };
return {
settings,
form,
discoveryConfig: {
dockerSocketPath: process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock',
traefikApiUrl: process.env.TRAEFIK_API_URL || ''
}
};
};
export const actions: Actions = {
+7 -1
View File
@@ -3,8 +3,12 @@
import type { PageData } from './$types.js';
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
import ImportExportPanel from '$lib/components/admin/ImportExportPanel.svelte';
import DiscoveryPanel from '$lib/components/admin/DiscoveryPanel.svelte';
let { data }: { data: PageData } = $props();
let dockerSocketPath = $state(data.discoveryConfig?.dockerSocketPath ?? '/var/run/docker.sock');
let traefikApiUrl = $state(data.discoveryConfig?.traefikApiUrl ?? '');
</script>
<svelte:head>
@@ -17,7 +21,9 @@
<p class="mt-1 text-sm text-muted-foreground">{$t('admin.settings_description')}</p>
</div>
<SettingsForm form={data.form} />
<SettingsForm form={data.form} bind:dockerSocketPath bind:traefikApiUrl />
<DiscoveryPanel bind:dockerSocketPath bind:traefikApiUrl />
<ImportExportPanel />
</div>
+41
View File
@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { discoverAll, type DiscoveryConfig } from '$lib/server/services/discoveryService.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* POST /api/admin/discover — Scan Docker and Traefik for services. Admin only.
*
* Body: { dockerSocketPath?: string, traefikApiUrl?: string }
*/
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
let body: DiscoveryConfig;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const config: DiscoveryConfig = {
dockerSocketPath: body.dockerSocketPath || undefined,
traefikApiUrl: body.traefikApiUrl || undefined
};
if (!config.dockerSocketPath && !config.traefikApiUrl) {
return json(
error('At least one discovery source must be configured (dockerSocketPath or traefikApiUrl)'),
{ status: 400 }
);
}
try {
const result = await discoverAll(config);
return json(success(result));
} catch (err) {
const message = err instanceof Error ? err.message : 'Discovery scan failed';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,70 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { create } from '$lib/server/services/appService.js';
import { success, error } from '$lib/server/utils/response.js';
interface ApproveServiceInput {
readonly name: string;
readonly url: string;
readonly source: 'docker' | 'traefik';
readonly icon?: string;
readonly description?: string;
}
interface ApproveBody {
readonly services: readonly ApproveServiceInput[];
}
/**
* POST /api/admin/discover/approve — Approve discovered services and create app entries. Admin only.
*
* Body: { services: DiscoveredService[] }
*/
export const POST: RequestHandler = async (event) => {
const user = requireAdmin(event);
let body: ApproveBody;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
if (!body.services || !Array.isArray(body.services) || body.services.length === 0) {
return json(error('At least one service must be provided for approval'), { status: 400 });
}
const created: string[] = [];
const errors: string[] = [];
for (const service of body.services) {
if (!service.name || !service.url) {
errors.push(`Skipped invalid service entry (missing name or url)`);
continue;
}
try {
const app = await create({
name: service.name,
url: service.url,
icon: service.icon,
description: service.description ?? `Discovered via ${service.source}`,
category: 'Discovered',
healthcheckEnabled: true,
createdById: user.id
});
created.push(app.id);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
errors.push(`Failed to create "${service.name}": ${message}`);
}
}
return json(
success({
created: created.length,
errors
})
);
};
+71
View File
@@ -0,0 +1,71 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as appService from '$lib/server/services/appService.js';
import { success, error } from '$lib/server/utils/response.js';
import { z } from 'zod';
const quickAddSchema = z.object({
url: z
.string()
.url('Invalid URL')
.refine(
(u) => u.startsWith('http://') || u.startsWith('https://'),
'URL must use http or https protocol'
),
name: z.string().min(1, 'Name is required').max(200),
description: z.string().max(1000).optional()
});
/**
* POST /api/apps/quick-add — Quick-add an app with sensible defaults.
* Accepts { url, name, description? }, creates app with healthcheck enabled
* and attempts to auto-detect a favicon icon from the URL's domain.
*/
export const POST: RequestHandler = async (event) => {
const user = requireAuth(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = quickAddSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
const { url, name, description } = parsed.data;
// Attempt to derive a favicon URL from the domain
let faviconUrl: string | undefined;
try {
const parsedUrl = new URL(url);
faviconUrl = `${parsedUrl.origin}/favicon.ico`;
} catch {
// URL parsing failed — skip icon detection
}
try {
const app = await appService.create({
name,
url,
description,
icon: faviconUrl,
iconType: faviconUrl ? 'url' : 'lucide',
healthcheckEnabled: true,
healthcheckInterval: 300,
healthcheckMethod: 'GET',
healthcheckExpectedStatus: 200,
healthcheckTimeout: 5000,
createdById: user.id
});
return json(success(app), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create app';
return json(error(message), { status: 500 });
}
};
+11
View File
@@ -3,10 +3,21 @@
import type { PageData } from './$types.js';
import AppCard from '$lib/components/app/AppCard.svelte';
import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
let { data }: { data: PageData } = $props();
let showForm = $state(false);
// Track app count to detect CRUD changes and broadcast to other tabs
let previousAppCount = $state(data.apps.length);
$effect(() => {
const currentCount = data.apps.length;
if (currentCount !== previousAppCount) {
broadcastDataChange('app');
previousAppCount = currentCount;
}
});
</script>
<svelte:head>
+64
View File
@@ -0,0 +1,64 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from '$lib/utils/zod-adapter.js';
import { fail } from '@sveltejs/kit';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as appService from '$lib/server/services/appService.js';
import { createAppSchema } from '$lib/utils/validators.js';
export const load: PageServerLoad = async (event) => {
requireAuth(event);
const url = event.url.searchParams.get('url') ?? '';
const name = event.url.searchParams.get('name') ?? '';
const form = await superValidate(zod(createAppSchema));
// Pre-fill from query params
if (url) form.data.url = url;
if (name) form.data.name = name;
// Set quick-add defaults
form.data.healthcheckEnabled = true;
form.data.healthcheckInterval = 300;
form.data.healthcheckMethod = 'GET';
form.data.healthcheckExpectedStatus = 200;
form.data.healthcheckTimeout = 5000;
// Attempt to auto-detect favicon
if (url) {
try {
const parsedUrl = new URL(url);
form.data.icon = `${parsedUrl.origin}/favicon.ico`;
form.data.iconType = 'url';
} catch {
// Invalid URL — skip icon detection
}
}
return { form };
};
export const actions: Actions = {
create: async (event) => {
const user = requireAuth(event);
const form = await superValidate(event.request, zod(createAppSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await appService.create({
...form.data,
createdById: user.id
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create app';
return setError(form, '', message);
}
return { form, created: true };
}
};
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import { page } from '$app/stores';
let { data }: { data: PageData } = $props();
// If successfully created, broadcast the change and offer to close
let created = $derived('created' in ($page.form ?? {}) && $page.form?.created === true);
$effect(() => {
if (created) {
broadcastDataChange('app');
}
});
function closeWindow() {
window.close();
}
</script>
<svelte:head>
<title>{$t('app.quick_add_title')} | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="mb-2 text-2xl font-bold text-foreground">{$t('app.quick_add_title')}</h1>
<p class="mb-6 text-sm text-muted-foreground">{$t('app.quick_add_description')}</p>
{#if created}
<div class="mb-6 rounded-lg border border-green-500/30 bg-green-500/10 p-4">
<p class="text-sm font-medium text-green-700 dark:text-green-300">
{$t('app.quick_add_success')}
</p>
<div class="mt-3 flex gap-3">
<a
href="/apps"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('app.quick_add_view_apps')}
</a>
<button
type="button"
onclick={closeWindow}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent"
>
{$t('app.quick_add_close')}
</button>
</div>
</div>
{:else}
<div class="rounded-xl border border-border bg-card p-6 shadow-sm">
<AppForm form={data.form} action="?/create" />
</div>
{/if}
</div>
+2
View File
@@ -5,6 +5,7 @@
import Board from '$lib/components/board/Board.svelte';
import BoardHeader from '$lib/components/board/BoardHeader.svelte';
import BoardShareDialog from '$lib/components/board/BoardShareDialog.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
let { data }: { data: PageData } = $props();
@@ -20,6 +21,7 @@
body: JSON.stringify({ isGuestAccessible: value })
});
if (res.ok) {
broadcastDataChange('board');
await invalidateAll();
} else {
guestToggleError = 'Failed to update guest access';
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { WifiOff, RefreshCw } from 'lucide-svelte';
function retry() {
window.location.reload();
}
</script>
<svelte:head>
<title>{$t('offline.title')}</title>
</svelte:head>
<div class="flex min-h-[60vh] flex-col items-center justify-center gap-6 px-4 text-center">
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-muted"
>
<WifiOff class="h-10 w-10 text-muted-foreground" />
</div>
<div class="space-y-2">
<h1 class="text-2xl font-bold text-foreground">
{$t('offline.title')}
</h1>
<p class="max-w-md text-muted-foreground">
{$t('offline.description')}
</p>
</div>
<button
type="button"
onclick={retry}
class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<RefreshCw class="h-4 w-4" />
{$t('offline.retry')}
</button>
</div>
+4 -1
View File
@@ -2,6 +2,7 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import ThemeCustomizer from '$lib/components/settings/ThemeCustomizer.svelte';
import BookmarkletGenerator from '$lib/components/settings/BookmarkletGenerator.svelte';
let { data }: { data: PageData } = $props();
</script>
@@ -10,8 +11,10 @@
<title>{$t('settings.title')} | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<div class="mx-auto max-w-2xl space-y-6 px-4 py-8">
<h1 class="mb-6 text-2xl font-bold text-foreground">{$t('settings.title')}</h1>
<ThemeCustomizer preferences={data.preferences} />
<BookmarkletGenerator />
</div>