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:
@@ -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));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user