mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
a1c3e99c7c
commit
22e06ef803
13 changed files with 1352 additions and 3 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
161
apps/manacore/apps/web/src/lib/components/PwaUpdatePrompt.svelte
Normal file
161
apps/manacore/apps/web/src/lib/components/PwaUpdatePrompt.svelte
Normal 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>
|
||||
58
apps/manacore/apps/web/src/lib/stores/network.svelte.ts
Normal file
58
apps/manacore/apps/web/src/lib/stores/network.svelte.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
BIN
apps/manacore/apps/web/static/apple-touch-icon.png
Normal file
BIN
apps/manacore/apps/web/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/manacore/apps/web/static/pwa-192x192.png
Normal file
BIN
apps/manacore/apps/web/static/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/manacore/apps/web/static/pwa-512x512.png
Normal file
BIN
apps/manacore/apps/web/static/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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' },
|
||||
],
|
||||
})
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue