feat(manacore/web): add PWA support with offline UX, update prompt, and icons

- Generate PWA icons (192x192, 512x512, apple-touch-icon) from favicon
- Add Apple PWA meta tags, theme-color, and viewport-fit=cover to app.html
- Upgrade caching preset to 'full' (adds font + CDN caching)
- Add manifest shortcuts for Dashboard, Todo, Calendar, Chat
- Switch registerType to 'prompt' for user-controlled updates
- Add OfflineIndicator component (offline banner + sync status badge)
- Add PwaUpdatePrompt component (detects waiting SW, skip-waiting on confirm)
- Add networkStore for online/offline + sync status tracking
- Wire sync manager status into networkStore for pending change counts
- Update offline page text to reflect local-first architecture
- Add mobile/desktop app strategy doc and Tauri v2 implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-05 17:15:36 +02:00
parent a1c3e99c7c
commit 22e06ef803
13 changed files with 1352 additions and 3 deletions

View file

@ -3,7 +3,12 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#6366f1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ManaCore" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -0,0 +1,180 @@
<script lang="ts">
import { networkStore } from '$lib/stores/network.svelte';
import { _ } from 'svelte-i18n';
let dismissed = $state(false);
// Reset dismissed state when coming back online
$effect(() => {
if (networkStore.isOnline) {
dismissed = false;
}
});
const showBanner = $derived(!networkStore.isOnline && !dismissed);
const showSyncBadge = $derived(
networkStore.isOnline && networkStore.pendingCount > 0 && networkStore.syncStatus !== 'syncing'
);
const showSyncing = $derived(networkStore.isOnline && networkStore.syncStatus === 'syncing');
</script>
{#if showBanner}
<div class="offline-banner" role="status" aria-live="polite">
<div class="offline-icon">
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor">
<path
d="M213.92,210.62a8,8,0,1,1-11.84,10.76l-39-42.86A83.93,83.93,0,0,0,48,128a84.37,84.37,0,0,0,.55,9.41,8,8,0,0,1-15.92,1.18A99.64,99.64,0,0,1,32,128a99.68,99.68,0,0,1,22.57-63.24L42.08,51.38A8,8,0,1,1,53.92,40.62ZM167.43,128a83.48,83.48,0,0,1-1.25,14.42,8,8,0,0,0,6.52,9.24,8.25,8.25,0,0,0,1.37.12,8,8,0,0,0,7.87-6.64A99.64,99.64,0,0,0,183.43,128a8,8,0,0,0-16,0Zm-32.53-56.88a8,8,0,0,0,5.15-15.13A100.06,100.06,0,0,0,32.63,128a8,8,0,0,0,16,0,84.07,84.07,0,0,1,86.27-83.88ZM231.43,128a8,8,0,0,0-16,0,84,84,0,0,1-14.82,47.64,8,8,0,0,0,13.22,9,100,100,0,0,0,17.6-56.64Zm-103.43,72a12,12,0,1,0,12,12A12,12,0,0,0,128,200Z"
/>
</svg>
</div>
<span class="offline-text"
>{$_('pwa.offline', { default: 'Offline — Änderungen werden lokal gespeichert' })}</span
>
<button
type="button"
class="dismiss-btn"
onclick={() => (dismissed = true)}
aria-label="Schliessen"
>
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor">
<path
d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"
/>
</svg>
</button>
</div>
{/if}
{#if showSyncBadge}
<div class="sync-badge" role="status" aria-live="polite">
<span class="sync-dot pending"></span>
<span class="sync-text">
{networkStore.pendingCount}
{$_('pwa.pendingChanges', { default: 'Änderungen warten auf Sync' })}
</span>
</div>
{/if}
{#if showSyncing}
<div class="sync-badge" role="status" aria-live="polite">
<span class="sync-dot syncing"></span>
<span class="sync-text">{$_('pwa.syncing', { default: 'Synchronisiere...' })}</span>
</div>
{/if}
<style>
.offline-banner {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: flex;
align-items: center;
gap: 8px;
background: rgba(239, 68, 68, 0.92);
color: white;
padding: 8px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
animation: slideDown 0.3s ease-out;
backdrop-filter: blur(8px);
}
@keyframes slideDown {
from {
transform: translateX(-50%) translateY(-20px);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
.offline-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.offline-text {
white-space: nowrap;
}
.dismiss-btn {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.15);
border: none;
color: white;
padding: 4px;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
margin-left: 4px;
}
.dismiss-btn:hover {
background: rgba(255, 255, 255, 0.25);
}
.sync-badge {
position: fixed;
top: 12px;
right: 12px;
z-index: 200;
display: flex;
align-items: center;
gap: 6px;
background: hsl(var(--color-card, 0 0% 10%) / 0.92);
color: hsl(var(--color-foreground, 0 0% 90%));
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fadeIn 0.2s ease-out;
backdrop-filter: blur(8px);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sync-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.sync-dot.pending {
background: #f59e0b;
}
.sync-dot.syncing {
background: #6366f1;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.sync-text {
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,161 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
let showPrompt = $state(false);
let registration = $state<ServiceWorkerRegistration | null>(null);
onMount(() => {
if (!('serviceWorker' in navigator)) return;
function onNewSW(reg: ServiceWorkerRegistration) {
registration = reg;
showPrompt = true;
}
navigator.serviceWorker.ready.then((reg) => {
// Already waiting worker present
if (reg.waiting) {
onNewSW(reg);
return;
}
// Watch for new installs
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
onNewSW(reg);
}
});
});
});
});
function handleUpdate() {
if (!registration?.waiting) return;
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Reload once the new SW takes over
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
function handleDismiss() {
showPrompt = false;
}
</script>
{#if showPrompt}
<div class="update-banner" role="alert">
<div class="update-content">
<svg class="update-icon" width="18" height="18" viewBox="0 0 256 256" fill="currentColor">
<path
d="M224,128a96,96,0,1,1-96-96A96.11,96.11,0,0,1,224,128Zm-40-8H136V80a8,8,0,0,0-16,0v48a8,8,0,0,0,8,8h48a8,8,0,0,0,0-16Z"
/>
</svg>
<span class="update-text">
{$_('pwa.updateAvailable', { default: 'Neue Version verfügbar' })}
</span>
</div>
<div class="update-actions">
<button type="button" class="update-btn" onclick={handleUpdate}>
{$_('pwa.updateNow', { default: 'Aktualisieren' })}
</button>
<button type="button" class="dismiss-btn" onclick={handleDismiss}>
{$_('pwa.later', { default: 'Später' })}
</button>
</div>
</div>
{/if}
<style>
.update-banner {
position: fixed;
bottom: 12px;
right: 12px;
z-index: 200;
display: flex;
align-items: center;
gap: 12px;
background: hsl(var(--color-card, 0 0% 10%) / 0.95);
color: hsl(var(--color-foreground, 0 0% 90%));
padding: 12px 16px;
border-radius: 12px;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
animation: slideUp 0.3s ease-out;
backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-foreground, 0 0% 90%) / 0.1);
max-width: 360px;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.update-content {
display: flex;
align-items: center;
gap: 8px;
}
.update-icon {
flex-shrink: 0;
color: #6366f1;
}
.update-text {
white-space: nowrap;
}
.update-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.update-btn {
background: #6366f1;
border: none;
color: white;
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.update-btn:hover {
background: #5558e6;
}
.dismiss-btn {
background: hsl(var(--color-foreground, 0 0% 90%) / 0.08);
border: none;
color: hsl(var(--color-foreground, 0 0% 90%) / 0.6);
padding: 5px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.dismiss-btn:hover {
background: hsl(var(--color-foreground, 0 0% 90%) / 0.15);
color: hsl(var(--color-foreground, 0 0% 90%) / 0.8);
}
</style>

View file

@ -0,0 +1,58 @@
/**
* Network status store tracks online/offline state and pending sync changes.
*
* Integrates with the unified sync manager to show sync status
* and pending change counts in the UI.
*/
import type { SyncStatus } from '$lib/data/sync';
let isOnline = $state(typeof navigator !== 'undefined' ? navigator.onLine : true);
let syncStatus = $state<SyncStatus>('idle');
let pendingCount = $state(0);
let cleanups: (() => void)[] = [];
function initialize() {
if (typeof window === 'undefined') return;
const handleOnline = () => (isOnline = true);
const handleOffline = () => (isOnline = false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
cleanups.push(() => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
});
}
function destroy() {
for (const fn of cleanups) fn();
cleanups = [];
}
function setSyncStatus(status: SyncStatus) {
syncStatus = status;
}
function setPendingCount(count: number) {
pendingCount = count;
}
export const networkStore = {
get isOnline() {
return isOnline;
},
get syncStatus() {
return syncStatus;
},
get pendingCount() {
return pendingCount;
},
initialize,
destroy,
setSyncStatus,
setPendingCount,
};

View file

@ -34,6 +34,8 @@
import { linkLocalStore, linkMutations } from '@manacore/shared-links';
import { manacoreStore } from '$lib/data/local-store';
import { createUnifiedSync } from '$lib/data/sync';
import { networkStore } from '$lib/stores/network.svelte';
import { db } from '$lib/data/database';
import { dashboardStore } from '$lib/stores/dashboard.svelte';
import {
THEME_DEFINITIONS,
@ -306,6 +308,16 @@
trackReturnVisit();
const getToken = () => authStore.getValidToken();
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken);
unifiedSync.onStatusChange(async (s) => {
networkStore.setSyncStatus(s);
// Update pending count when sync status changes
try {
const count = await db.table('_pendingChanges').count();
networkStore.setPendingCount(count);
} catch {
// DB not ready yet
}
});
unifiedSync.startAll();
userSettings.load().catch(() => {});

View file

@ -3,8 +3,11 @@
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { networkStore } from '$lib/stores/network.svelte';
import { loadAutomations } from '$lib/triggers';
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
let { children } = $props();
@ -12,6 +15,9 @@
// Initialize theme
const cleanupTheme = theme.initialize();
// Initialize network status tracking
networkStore.initialize();
// Initialize auth
await authStore.initialize();
@ -20,9 +26,12 @@
return () => {
cleanupTheme();
networkStore.destroy();
};
});
</script>
{@render children()}
<SuggestionToast />
<OfflineIndicator />
<PwaUpdatePrompt />

View file

@ -4,6 +4,6 @@
<OfflinePage
appName="ManaCore"
offlineMessage="ManaCore benötigt eine Internetverbindung."
accentColor="#4f46e5"
offlineMessage="Du bist offline. Deine Daten sind lokal gespeichert und werden synchronisiert, sobald du wieder online bist."
accentColor="#6366f1"
/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -18,6 +18,24 @@ export default defineConfig({
shortName: 'ManaCore',
description: 'ManaCore App Ecosystem',
themeColor: '#6366f1',
registerType: 'prompt',
preset: 'full',
shortcuts: [
{ name: 'Dashboard', short_name: 'Home', url: '/', description: 'Zum Dashboard' },
{
name: 'Neue Aufgabe',
short_name: 'Aufgabe',
url: '/todo',
description: 'Neue Aufgabe erstellen',
},
{
name: 'Kalender',
short_name: 'Kalender',
url: '/calendar',
description: 'Kalender öffnen',
},
{ name: 'Chat', short_name: 'Chat', url: '/chat', description: 'Chat öffnen' },
],
})
),
],