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:
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface DiscoveredService {
|
||||
name: string;
|
||||
url: string;
|
||||
source: 'docker' | 'traefik';
|
||||
icon?: string;
|
||||
description?: string;
|
||||
alreadyRegistered: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
dockerSocketPath = $bindable('/var/run/docker.sock'),
|
||||
traefikApiUrl = $bindable('')
|
||||
}: {
|
||||
dockerSocketPath?: string;
|
||||
traefikApiUrl?: string;
|
||||
} = $props();
|
||||
|
||||
let scanning = $state(false);
|
||||
let approving = $state(false);
|
||||
let services = $state<DiscoveredService[]>([]);
|
||||
let scanErrors = $state<string[]>([]);
|
||||
let selected = $state<Set<number>>(new Set());
|
||||
let statusMessage = $state('');
|
||||
let statusType: 'success' | 'error' | '' = $state('');
|
||||
|
||||
function clearStatus() {
|
||||
statusMessage = '';
|
||||
statusType = '';
|
||||
}
|
||||
|
||||
async function handleScan() {
|
||||
clearStatus();
|
||||
scanning = true;
|
||||
services = [];
|
||||
scanErrors = [];
|
||||
selected = new Set();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/discover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dockerSocketPath: dockerSocketPath || undefined,
|
||||
traefikApiUrl: traefikApiUrl || undefined
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Discovery scan failed');
|
||||
}
|
||||
|
||||
services = result.data.services;
|
||||
scanErrors = result.data.errors;
|
||||
|
||||
if (services.length === 0) {
|
||||
statusMessage = $t('admin.discovery_no_results');
|
||||
statusType = 'error';
|
||||
}
|
||||
} catch (err) {
|
||||
statusMessage = err instanceof Error ? err.message : 'Scan failed';
|
||||
statusType = 'error';
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(index: number) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const selectableIndices = services
|
||||
.map((s, i) => (s.alreadyRegistered ? -1 : i))
|
||||
.filter((i) => i >= 0);
|
||||
|
||||
if (selected.size === selectableIndices.length) {
|
||||
selected = new Set();
|
||||
} else {
|
||||
selected = new Set(selectableIndices);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (selected.size === 0) return;
|
||||
|
||||
clearStatus();
|
||||
approving = true;
|
||||
|
||||
const toApprove = Array.from(selected).map((i) => services[i]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/discover/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ services: toApprove })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Approval failed');
|
||||
}
|
||||
|
||||
const { created, errors: approveErrors } = result.data;
|
||||
const parts: string[] = [];
|
||||
if (created > 0) parts.push(`${created} app(s) created`);
|
||||
if (approveErrors.length > 0) parts.push(approveErrors.join('; '));
|
||||
|
||||
statusMessage = `${$t('admin.discovery_approve')}: ${parts.join('. ')}`;
|
||||
statusType = approveErrors.length > 0 ? 'error' : 'success';
|
||||
|
||||
// Mark approved services as registered
|
||||
services = services.map((s, i) =>
|
||||
selected.has(i) ? { ...s, alreadyRegistered: true } : s
|
||||
);
|
||||
selected = new Set();
|
||||
} catch (err) {
|
||||
statusMessage = err instanceof Error ? err.message : 'Approval failed';
|
||||
statusType = 'error';
|
||||
} finally {
|
||||
approving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_title')}</h2>
|
||||
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
|
||||
|
||||
<!-- Scan Button -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleScan}
|
||||
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scan Errors -->
|
||||
{#if scanErrors.length > 0}
|
||||
<div class="mb-4 rounded-md bg-yellow-100 p-3 text-sm text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
{#each scanErrors as scanError}
|
||||
<p>{scanError}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results Table -->
|
||||
{#if services.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="px-2 py-2 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === selectableCount && selectableCount > 0}
|
||||
onchange={toggleSelectAll}
|
||||
disabled={selectableCount === 0}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">URL</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('admin.discovery_source')}</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('admin.discovery_status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each services as service, i}
|
||||
<tr class="border-b border-border/50 hover:bg-muted/50">
|
||||
<td class="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(i)}
|
||||
onchange={() => toggleSelect(i)}
|
||||
disabled={service.alreadyRegistered}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
|
||||
<td class="px-2 py-2">
|
||||
<a href={service.url} target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">
|
||||
{service.url}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{service.source === 'docker'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}"
|
||||
>
|
||||
{service.source === 'docker' ? $t('admin.discovery_source_docker') : $t('admin.discovery_source_traefik')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
{#if service.alreadyRegistered}
|
||||
<span class="text-xs text-muted-foreground">{$t('admin.discovery_already_registered')}</span>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-green-600 dark:text-green-400">{$t('admin.discovery_new')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Approve button -->
|
||||
{#if selectableCount > 0}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleApprove}
|
||||
disabled={approving || selected.size === 0}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Status Message -->
|
||||
{#if statusMessage}
|
||||
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
|
||||
{statusMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -4,7 +4,15 @@
|
||||
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
|
||||
let {
|
||||
form: formData,
|
||||
dockerSocketPath = $bindable('/var/run/docker.sock'),
|
||||
traefikApiUrl = $bindable('')
|
||||
}: {
|
||||
form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>>;
|
||||
dockerSocketPath?: string;
|
||||
traefikApiUrl?: string;
|
||||
} = $props();
|
||||
|
||||
const { form, errors, enhance, delayed } = superForm(formData);
|
||||
|
||||
@@ -186,6 +194,36 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Service Discovery Configuration -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_config')}</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.discovery_config_description')}</p>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="dockerSocketPath" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.discovery_docker_socket')}</label>
|
||||
<input
|
||||
id="dockerSocketPath"
|
||||
type="text"
|
||||
bind:value={dockerSocketPath}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="/var/run/docker.sock"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_docker_socket_hint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="traefikApiUrl" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.discovery_traefik_url')}</label>
|
||||
<input
|
||||
id="traefikApiUrl"
|
||||
type="url"
|
||||
bind:value={traefikApiUrl}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="http://traefik:8080"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_traefik_url_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if $errors._errors}
|
||||
<p class="text-sm text-destructive">{$errors._errors}</p>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { Download, X } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const DISMISS_KEY = 'wal-install-prompt-dismissed';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = $state(null);
|
||||
let dismissed = $state(false);
|
||||
let installed = $state(false);
|
||||
|
||||
const visible = $derived(deferredPrompt !== null && !dismissed && !installed);
|
||||
|
||||
function isDismissed(): boolean {
|
||||
if (!browser) return false;
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
dismissed = true;
|
||||
if (browser) {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, 'true');
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
installed = true;
|
||||
}
|
||||
deferredPrompt = null;
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
dismissed = isDismissed();
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="fixed bottom-4 left-4 right-4 z-50 mx-auto flex max-w-lg items-center gap-3 rounded-xl border border-border bg-card p-4 shadow-lg"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Download class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{$t('install.title')}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{$t('install.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={install}
|
||||
class="shrink-0 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{$t('install.button')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismiss}
|
||||
class="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={$t('install.dismiss')}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import Sidebar from './Sidebar.svelte';
|
||||
import Header from './Header.svelte';
|
||||
import InstallPrompt from './InstallPrompt.svelte';
|
||||
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
|
||||
import SearchDialog from '$lib/components/search/SearchDialog.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte.js';
|
||||
@@ -66,3 +67,6 @@
|
||||
|
||||
<!-- Search Dialog (modal, z-50) -->
|
||||
<SearchDialog />
|
||||
|
||||
<!-- PWA Install Prompt -->
|
||||
<InstallPrompt />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let origin = $state('');
|
||||
let bookmarkletCode = $state('');
|
||||
let bookmarkletHref = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
origin = window.location.origin;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!origin) return;
|
||||
// The bookmarklet opens the quick-add page with URL and title pre-filled
|
||||
const code = `javascript:void(window.open('${origin}/apps/quick-add?url='+encodeURIComponent(location.href)+'&name='+encodeURIComponent(document.title),'_blank','width=600,height=500'))`;
|
||||
bookmarkletCode = code;
|
||||
bookmarkletHref = code;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<h2 class="mb-2 text-lg font-semibold text-card-foreground">
|
||||
{$t('settings.bookmarklet_title')}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
{$t('settings.bookmarklet_instructions')}
|
||||
</p>
|
||||
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<a
|
||||
href={bookmarkletHref}
|
||||
class="inline-flex items-center gap-2 rounded-lg border-2 border-dashed border-primary/50 bg-primary/10 px-4 py-2 text-sm font-medium text-primary transition-colors hover:border-primary hover:bg-primary/20"
|
||||
onclick={(e) => { e.preventDefault(); }}
|
||||
draggable="true"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
||||
</svg>
|
||||
{$t('settings.bookmarklet_drag')}
|
||||
</a>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{$t('settings.bookmarklet_drag_hint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
{$t('settings.bookmarklet_show_code')}
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto rounded-md border border-border bg-background p-3 text-xs text-foreground"><code>{bookmarkletCode}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
Reference in New Issue
Block a user