feat(pwa): Admin-Tab "App" mit Install + Sync + Cache-Reset
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Neuer vierter Admin-Tab (Smartphone-Icon) mit drei Karten: 1. Installieren — fängt beforeinstallprompt (Android), zeigt iOS-Teilen-Hinweis, sonst Info "nicht verfügbar". 2. Offline-Synchronisation — Status + "Jetzt synchronisieren"- Button, disabled wenn offline. 3. Cache — "Offline-Cache leeren" löscht alle kochwas-*-Caches via caches.keys() + delete. install-prompt.svelte.ts hält das deferred-Event und die Plattform (android/ios/other) per UA-Detection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
44
src/lib/client/install-prompt.svelte.ts
Normal file
44
src/lib/client/install-prompt.svelte.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Captures the beforeinstallprompt event (Android Chrome) and holds it for
|
||||
// manual triggering by the user. On iOS Safari this event does not exist —
|
||||
// we detect the browser via UserAgent and show an info hint instead.
|
||||
class InstallPromptStore {
|
||||
available = $state(false);
|
||||
platform = $state<'android' | 'ios' | 'other'>('other');
|
||||
private deferred: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
init(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
this.platform = detectPlatform();
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
this.deferred = e as BeforeInstallPromptEvent;
|
||||
this.available = true;
|
||||
});
|
||||
window.addEventListener('appinstalled', () => {
|
||||
this.deferred = null;
|
||||
this.available = false;
|
||||
});
|
||||
}
|
||||
|
||||
async prompt(): Promise<void> {
|
||||
if (!this.deferred) return;
|
||||
await this.deferred.prompt();
|
||||
this.deferred = null;
|
||||
this.available = false;
|
||||
}
|
||||
}
|
||||
|
||||
function detectPlatform(): 'android' | 'ios' | 'other' {
|
||||
const ua = navigator.userAgent;
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Minimal type for the Chrome-specific event
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
};
|
||||
|
||||
export const installPrompt = new InstallPromptStore();
|
||||
@@ -15,6 +15,7 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
|
||||
import { network } from '$lib/client/network.svelte';
|
||||
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||
import { registerServiceWorker } from '$lib/client/sw-register';
|
||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||
import type { WebHit } from '$lib/server/search/searxng';
|
||||
@@ -207,6 +208,7 @@
|
||||
void searchFilterStore.load();
|
||||
void pwaStore.init();
|
||||
network.init();
|
||||
installPrompt.init();
|
||||
void registerServiceWorker();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Globe, Users, DatabaseBackup, type Icon } from 'lucide-svelte';
|
||||
import { Globe, Users, DatabaseBackup, Smartphone, type Icon } from 'lucide-svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const items: { href: string; label: string; icon: typeof Icon }[] = [
|
||||
{ href: '/admin/domains', label: 'Domains', icon: Globe },
|
||||
{ href: '/admin/profiles', label: 'Profile', icon: Users },
|
||||
{ href: '/admin/backup', label: 'Backup', icon: DatabaseBackup }
|
||||
{ href: '/admin/backup', label: 'Backup', icon: DatabaseBackup },
|
||||
{ href: '/admin/app', label: 'App', icon: Smartphone }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
142
src/routes/admin/app/+page.svelte
Normal file
142
src/routes/admin/app/+page.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { Download, RefreshCw, Trash2 } from 'lucide-svelte';
|
||||
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||
import { syncStatus } from '$lib/client/sync-status.svelte';
|
||||
import { network } from '$lib/client/network.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { toastStore } from '$lib/client/toast.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
function triggerInstall() {
|
||||
void installPrompt.prompt();
|
||||
}
|
||||
|
||||
function triggerSync() {
|
||||
if (!requireOnline('Das Synchronisieren')) return;
|
||||
navigator.serviceWorker?.controller?.postMessage({ type: 'sync-check' });
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
const ok = await confirmAction({
|
||||
title: 'Offline-Cache leeren?',
|
||||
message:
|
||||
'Alle lokal gespeicherten Rezepte und Bilder werden entfernt. Beim nächsten Online-Start werden sie neu geladen.',
|
||||
confirmLabel: 'Leeren',
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter((k) => k.startsWith('kochwas-')).map((k) => caches.delete(k)));
|
||||
toastStore.success('Cache geleert. Lade jetzt neu.');
|
||||
}
|
||||
|
||||
function formatTime(ts: number | null): string {
|
||||
if (ts === null) return 'noch nicht';
|
||||
return new Date(ts).toLocaleString('de-DE');
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>App</h1>
|
||||
<p class="intro">Einstellungen für die Installation und den Offline-Cache.</p>
|
||||
|
||||
<section class="card">
|
||||
<h2>Installieren</h2>
|
||||
{#if installPrompt.platform === 'ios'}
|
||||
<p>
|
||||
Öffne das Teilen-Menü in Safari und wähle <strong
|
||||
>„Zum Home-Bildschirm hinzufügen"</strong
|
||||
>.
|
||||
</p>
|
||||
{:else if installPrompt.available}
|
||||
<button type="button" class="btn primary" onclick={triggerInstall}>
|
||||
<Download size={16} strokeWidth={2} /> Als App installieren
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
Installation aktuell nicht möglich (entweder schon installiert oder Browser unterstützt es
|
||||
nicht).
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Offline-Synchronisation</h2>
|
||||
{#if syncStatus.state.kind === 'syncing'}
|
||||
<p>Lädt gerade: {syncStatus.state.current}/{syncStatus.state.total} Rezepte.</p>
|
||||
{:else if syncStatus.state.kind === 'error'}
|
||||
<p class="error">Fehler: {syncStatus.state.message}</p>
|
||||
{:else}
|
||||
<p>Zuletzt synchronisiert: {formatTime(syncStatus.lastSynced)}</p>
|
||||
{/if}
|
||||
<button type="button" class="btn" onclick={triggerSync} disabled={!network.online}>
|
||||
<RefreshCw size={16} strokeWidth={2} /> Jetzt synchronisieren
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Cache</h2>
|
||||
<p class="muted">Nur bei Problemen: entfernt alle Offline-Daten.</p>
|
||||
<button type="button" class="btn danger" onclick={clearCache}>
|
||||
<Trash2 size={16} strokeWidth={2} /> Offline-Cache leeren
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.intro {
|
||||
color: #666;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: #2b6a3d;
|
||||
}
|
||||
.card p {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
}
|
||||
.error {
|
||||
color: #c53030;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.92rem;
|
||||
min-height: 40px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border: 0;
|
||||
}
|
||||
.btn.danger {
|
||||
color: #c53030;
|
||||
border-color: #f1b4b4;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user