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 Toast from '$lib/components/Toast.svelte';
|
||||||
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
|
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
|
||||||
import { network } from '$lib/client/network.svelte';
|
import { network } from '$lib/client/network.svelte';
|
||||||
|
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||||
import { registerServiceWorker } from '$lib/client/sw-register';
|
import { registerServiceWorker } from '$lib/client/sw-register';
|
||||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||||
import type { WebHit } from '$lib/server/search/searxng';
|
import type { WebHit } from '$lib/server/search/searxng';
|
||||||
@@ -207,6 +208,7 @@
|
|||||||
void searchFilterStore.load();
|
void searchFilterStore.load();
|
||||||
void pwaStore.init();
|
void pwaStore.init();
|
||||||
network.init();
|
network.init();
|
||||||
|
installPrompt.init();
|
||||||
void registerServiceWorker();
|
void registerServiceWorker();
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
document.addEventListener('keydown', handleKey);
|
document.addEventListener('keydown', handleKey);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
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();
|
let { children } = $props();
|
||||||
|
|
||||||
const items: { href: string; label: string; icon: typeof Icon }[] = [
|
const items: { href: string; label: string; icon: typeof Icon }[] = [
|
||||||
{ href: '/admin/domains', label: 'Domains', icon: Globe },
|
{ href: '/admin/domains', label: 'Domains', icon: Globe },
|
||||||
{ href: '/admin/profiles', label: 'Profile', icon: Users },
|
{ 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>
|
</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