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,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 />
|
||||
|
||||
Reference in New Issue
Block a user