feat(offline): redesign sync UI and PWA polish
CI / update (push) Successful in 4m49s

Split the single OfflineSyncButton into two surfaces with distinct
intents:
  - OfflineSyncBanner: dismissable promo on the recipe index that
    encourages first-time download (only when standalone + not yet
    synced).
  - OfflineSyncIndicator: small status pip overlaid on the nav logo
    when offline data is available, opening a popover with sync /
    clear actions.

Also fold the sync / clear actions into the UserHeader options menu so
the avatar dropdown is the canonical place to manage offline data.
Header.svelte gains a `logo_overlay` snippet slot to host the
indicator pip.

Other:
  - manifest.json: prefer the theme-aware SVG as the primary install
    icon and drop the redundant 512px raster (kept maskable 192px).
  - scripts/deploy.sh: build locally and rsync artifacts to the
    server, avoiding any pnpm/git work on the production host.

Bump 1.57.8 -> 1.58.0.
This commit is contained in:
2026-05-02 15:56:21 +02:00
parent 6875e8762e
commit 2af845bfc6
12 changed files with 796 additions and 283 deletions
+12 -1
View File
@@ -12,6 +12,7 @@ let {
language_selector_mobile,
language_selector_desktop,
right_side,
logo_overlay,
children,
fullSymbol = false
}: {
@@ -19,6 +20,7 @@ let {
language_selector_mobile?: Snippet;
language_selector_desktop?: Snippet;
right_side?: Snippet;
logo_overlay?: Snippet;
children?: Snippet;
fullSymbol?: boolean;
} = $props();
@@ -118,6 +120,12 @@ nav {
/* ═══════════════════════════════════════════
LOGO
═══════════════════════════════════════════ */
.logo-slot {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.home-link {
view-transition-name: nav-logo;
display: flex;
@@ -330,7 +338,10 @@ nav {
<div>
<nav class:no-links={!links}>
<a href={resolve('/')} aria-label="Home" class="home-link" class:full={fullSymbol}><Symbol /></a>
<div class="logo-slot">
<a href={resolve('/')} aria-label="Home" class="home-link" class:full={fullSymbol}><Symbol /></a>
{@render logo_overlay?.()}
</div>
{#if links}
<div class="links-wrapper">
{@render links()}
+245
View File
@@ -0,0 +1,245 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pwaStore } from '$lib/stores/pwa.svelte';
import { m, type CommonLang } from '$lib/js/commonI18n';
import Download from '@lucide/svelte/icons/download';
import X from '@lucide/svelte/icons/x';
let { lang = 'de' }: { lang?: string } = $props();
const DISMISS_KEY = 'bocken-offline-banner-dismissed';
let mounted = $state(false);
let dismissed = $state(false);
const t = $derived(m[lang as CommonLang]);
onMount(async () => {
dismissed = localStorage.getItem(DISMISS_KEY) === '1';
mounted = true;
await pwaStore.initialize();
});
function dismiss() {
dismissed = true;
localStorage.setItem(DISMISS_KEY, '1');
}
async function handleSync() {
await pwaStore.syncForOffline();
}
const visible = $derived(
mounted &&
pwaStore.isStandalone &&
!pwaStore.isOfflineAvailable &&
(!dismissed || pwaStore.isSyncing)
);
const progressPct = $derived.by(() => {
const ip = pwaStore.syncProgress?.imageProgress;
if (!ip || ip.total === 0) return 0;
return Math.round((ip.completed / ip.total) * 100);
});
</script>
<style>
.banner {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: center;
max-width: 1200px;
margin: 1rem auto;
padding: 0.9rem 1.1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
}
.banner::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: var(--green, var(--nord14));
}
.icon-wrap {
display: grid;
place-items: center;
width: 2.4rem;
height: 2.4rem;
border-radius: 50%;
background: color-mix(in oklab, var(--green, var(--nord14)) 18%, transparent);
color: var(--green, var(--nord14));
flex-shrink: 0;
}
.copy {
min-width: 0;
}
.title {
font-weight: 600;
font-size: 0.95rem;
color: var(--color-text-primary);
line-height: 1.25;
}
.body {
font-size: 0.82rem;
color: var(--color-text-secondary);
margin-top: 0.15rem;
line-height: 1.35;
}
.actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.sync-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.95rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text-on-primary);
background: var(--color-primary);
border: none;
border-radius: var(--radius-pill);
cursor: pointer;
transition: var(--transition-fast);
white-space: nowrap;
}
.sync-btn:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: scale(1.03);
}
.sync-btn:disabled {
opacity: 0.7;
cursor: progress;
}
.dismiss-btn {
background: transparent;
border: none;
color: var(--color-text-tertiary);
padding: 0.35rem;
border-radius: 50%;
cursor: pointer;
display: grid;
place-items: center;
transition: var(--transition-fast);
}
.dismiss-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.progress {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.35rem;
}
.progress-text {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.progress-bar {
height: 4px;
width: 100%;
background: var(--color-bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--green, var(--nord14));
transition: width 200ms ease-out;
}
.error {
grid-column: 1 / -1;
font-size: 0.78rem;
color: var(--red, var(--nord11));
margin-top: 0.3rem;
}
@media (max-width: 560px) {
.banner {
grid-template-columns: auto 1fr auto;
gap: 0.7rem;
padding: 0.75rem 0.9rem;
margin: 0.75rem auto;
}
.body {
display: none;
}
.sync-btn {
padding: 0.45rem 0.75rem;
font-size: 0.8rem;
}
}
</style>
{#if visible}
<aside class="banner" role="status" aria-live="polite">
<div class="icon-wrap" aria-hidden="true">
<Download size={20} strokeWidth={2} />
</div>
<div class="copy">
<div class="title">{t.offline_banner_title}</div>
<div class="body">{t.offline_banner_body}</div>
</div>
<div class="actions">
<button
class="sync-btn"
onclick={handleSync}
disabled={pwaStore.isSyncing}
type="button"
>
{pwaStore.isSyncing ? t.syncing : t.offline_banner_action}
</button>
{#if !pwaStore.isSyncing}
<button
class="dismiss-btn"
onclick={dismiss}
aria-label={t.dismiss}
title={t.dismiss}
type="button"
>
<X size={16} strokeWidth={2} />
</button>
{/if}
</div>
{#if pwaStore.isSyncing && pwaStore.syncProgress}
<div class="progress">
<div class="progress-text">
{pwaStore.syncProgress.message}
{#if pwaStore.syncProgress.imageProgress}
· {progressPct}%
{/if}
</div>
{#if pwaStore.syncProgress.imageProgress}
<div class="progress-bar">
<div class="progress-fill" style="width: {progressPct}%"></div>
</div>
{/if}
</div>
{/if}
{#if pwaStore.error}
<div class="error">{pwaStore.error}</div>
{/if}
</aside>
{/if}
-257
View File
@@ -1,257 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pwaStore } from '$lib/stores/pwa.svelte';
import { m, type CommonLang } from '$lib/js/commonI18n';
let { lang = 'de' }: { lang?: string } = $props();
let showTooltip = $state(false);
let mounted = $state(false);
const t = $derived(m[lang as CommonLang]);
const labels = $derived({
syncForOffline: t.sync_for_offline,
syncing: t.syncing,
offlineReady: t.offline_ready,
lastSync: t.last_sync,
recipes: t.recipes_word,
syncNow: t.sync_now,
clearData: t.clear_offline_data
});
onMount(async () => {
mounted = true;
// Initialize PWA store (checks standalone mode, starts auto-sync if needed)
await pwaStore.initialize();
});
async function handleSync() {
await pwaStore.syncForOffline();
}
async function handleClear() {
await pwaStore.clearOfflineData();
showTooltip = false;
}
function formatDate(isoString: string | null): string {
if (!isoString) return '';
const date = new Date(isoString);
return date.toLocaleDateString(lang === 'en' ? 'en-US' : 'de-DE', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<style>
.offline-sync {
position: relative;
}
.sync-button {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
transition: color 100ms;
}
.sync-button:hover,
.sync-button:focus {
color: var(--nord8);
}
.sync-button.syncing {
animation: pulse 1s infinite;
}
.sync-button.available {
color: var(--nord14);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.sync-icon {
width: 1.5rem;
height: 1.5rem;
fill: currentColor;
}
.tooltip {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: var(--nord0);
border: 1px solid var(--nord3);
border-radius: 0.5rem;
padding: 1rem;
min-width: 200px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.tooltip-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.status {
font-size: 0.875rem;
color: var(--nord4);
}
.status.ready {
color: var(--nord14);
}
.tooltip-button {
background: var(--nord3);
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
transition: background 100ms;
}
.tooltip-button:hover {
background: var(--nord2);
}
.tooltip-button.clear {
background: var(--nord11);
}
.tooltip-button.clear:hover {
background: #c04040;
}
.tooltip-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.meta {
font-size: 0.75rem;
color: var(--nord4);
}
.progress-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.progress-text {
font-size: 0.75rem;
color: var(--nord4);
}
.progress-bar {
width: 100%;
height: 4px;
background: var(--nord3);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--nord14);
transition: width 150ms ease-out;
}
</style>
{#if mounted && pwaStore.isStandalone}
<div class="offline-sync">
<button
class="sync-button"
class:syncing={pwaStore.isSyncing}
class:available={pwaStore.isOfflineAvailable}
onclick={() => showTooltip = !showTooltip}
title={pwaStore.isOfflineAvailable ? labels.offlineReady : labels.syncForOffline}
>
<svg class="sync-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
{#if pwaStore.isOfflineAvailable}
<!-- Checkmark icon when offline data is available -->
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
{:else}
<!-- Download icon when no offline data -->
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
{/if}
</svg>
</button>
{#if showTooltip}
<div class="tooltip">
<div class="tooltip-content">
{#if pwaStore.isOfflineAvailable}
<div class="status ready">{labels.offlineReady}</div>
<div class="meta">
{pwaStore.recipeCount} {labels.recipes}
{#if pwaStore.lastSyncDate}
<br>{labels.lastSync}: {formatDate(pwaStore.lastSyncDate)}
{/if}
</div>
<button
class="tooltip-button"
onclick={handleSync}
disabled={pwaStore.isSyncing}
>
{pwaStore.isSyncing ? labels.syncing : labels.syncNow}
</button>
<button
class="tooltip-button clear"
onclick={handleClear}
disabled={pwaStore.isSyncing}
>
{labels.clearData}
</button>
{:else}
<div class="status">{labels.syncForOffline}</div>
<button
class="tooltip-button"
onclick={handleSync}
disabled={pwaStore.isSyncing}
>
{pwaStore.isSyncing ? labels.syncing : labels.syncForOffline}
</button>
{/if}
{#if pwaStore.isSyncing && pwaStore.syncProgress}
<div class="progress-container">
<div class="progress-text">{pwaStore.syncProgress.message}</div>
{#if pwaStore.syncProgress.imageProgress}
<div class="progress-bar">
<div
class="progress-fill"
style="width: {(pwaStore.syncProgress.imageProgress.completed / pwaStore.syncProgress.imageProgress.total) * 100}%"
></div>
</div>
{/if}
</div>
{/if}
{#if pwaStore.error}
<div class="status" style="color: var(--nord11);">
{pwaStore.error}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,300 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pwaStore } from '$lib/stores/pwa.svelte';
import { m, type CommonLang } from '$lib/js/commonI18n';
let { lang = 'de' }: { lang?: string } = $props();
let mounted = $state(false);
let open = $state(false);
const t = $derived(m[lang as CommonLang]);
onMount(async () => {
mounted = true;
await pwaStore.initialize();
});
function closeOnOutsideClick(node: HTMLElement) {
const handler = (e: MouseEvent) => {
if (!open) return;
if (!node.contains(e.target as Node)) {
open = false;
}
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}
function formatDate(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString(lang === 'en' ? 'en-US' : 'de-DE', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
async function handleSync() {
await pwaStore.syncForOffline();
}
async function handleClear() {
await pwaStore.clearOfflineData();
open = false;
}
const visible = $derived(
mounted && pwaStore.isStandalone && pwaStore.isOfflineAvailable
);
const progressPct = $derived.by(() => {
const ip = pwaStore.syncProgress?.imageProgress;
if (!ip || ip.total === 0) return 0;
return Math.round((ip.completed / ip.total) * 100);
});
</script>
<style>
.wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.pip {
--size: 8px;
appearance: none;
background: transparent;
border: none;
padding: 3px;
display: grid;
place-items: center;
cursor: pointer;
border-radius: 50%;
transition: background 150ms;
}
.pip:hover {
background: var(--nav-hover-bg, rgba(255,255,255,0.12));
}
.dot {
width: var(--size);
height: var(--size);
border-radius: 50%;
background: var(--green, var(--nord14));
box-shadow:
0 0 0 2px color-mix(in oklab, var(--green, var(--nord14)) 70%, transparent),
0 0 6px color-mix(in oklab, var(--green, var(--nord14)) 90%, transparent);
transition: transform 150ms;
}
.pip:hover .dot {
transform: scale(1.18);
}
.dot.syncing {
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.85); }
}
.popover {
--menu-bg: rgba(46, 52, 64, 0.95);
--menu-border: rgba(255,255,255,0.08);
--menu-text: rgba(255,255,255,0.92);
--menu-text-secondary: rgba(255,255,255,0.6);
position: absolute;
top: calc(100% + 10px);
left: -8px;
min-width: 240px;
background: var(--menu-bg);
color: var(--menu-text);
border: 1px solid var(--menu-border);
border-radius: 10px;
padding: 0.85rem 0.95rem;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
z-index: 50;
backdrop-filter: blur(16px);
}
.popover::before {
content: '';
position: absolute;
top: -7px;
left: 0.7rem;
border: 7px solid transparent;
border-bottom-color: var(--menu-bg);
border-top: 0;
}
@media (prefers-color-scheme: dark) {
.popover { --menu-bg: rgba(20, 20, 20, 0.95); }
}
:global(:root[data-theme="dark"]) .popover {
--menu-bg: rgba(20, 20, 20, 0.95);
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .popover {
--menu-bg: rgba(255, 255, 255, 0.97);
--menu-border: rgba(0,0,0,0.08);
--menu-text: var(--color-text-primary);
--menu-text-secondary: var(--color-text-secondary);
}
}
:global(:root[data-theme="light"]) .popover {
--menu-bg: rgba(255, 255, 255, 0.97);
--menu-border: rgba(0,0,0,0.08);
--menu-text: var(--color-text-primary);
--menu-text-secondary: var(--color-text-secondary);
}
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--green, var(--nord14));
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green, var(--nord14));
}
.meta {
font-size: 0.75rem;
color: var(--menu-text-secondary);
margin-top: 0.35rem;
line-height: 1.4;
}
.divider {
height: 1px;
background: var(--menu-border);
margin: 0.7rem 0;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.action {
appearance: none;
text-align: left;
background: transparent;
border: none;
color: var(--menu-text);
font-size: 0.85rem;
padding: 0.45rem 0.55rem;
border-radius: 6px;
cursor: pointer;
transition: background 120ms;
}
.action:hover:not(:disabled) {
background: color-mix(in oklab, var(--menu-text) 10%, transparent);
}
.action.danger { color: var(--red, var(--nord11)); }
.action:disabled { opacity: 0.5; cursor: not-allowed; }
.progress {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.progress-text {
font-size: 0.72rem;
color: var(--menu-text-secondary);
}
.progress-bar {
width: 100%;
height: 3px;
background: color-mix(in oklab, var(--menu-text) 12%, transparent);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--green, var(--nord14));
transition: width 200ms ease-out;
}
.error {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--red, var(--nord11));
}
</style>
{#if visible}
<div class="wrap" {@attach closeOnOutsideClick}>
<button
class="pip"
onclick={() => open = !open}
aria-label={t.offline_ready}
aria-expanded={open}
title={t.offline_ready}
type="button"
>
<span class="dot" class:syncing={pwaStore.isSyncing}></span>
</button>
{#if open}
<div class="popover" role="dialog">
<div class="status-row">
<span class="status-dot"></span>
{t.offline_ready}
</div>
<div class="meta">
{pwaStore.recipeCount} {t.recipes_word}
{#if pwaStore.lastSyncDate}
<br />{t.last_sync}: {formatDate(pwaStore.lastSyncDate)}
{/if}
</div>
<div class="divider"></div>
<div class="actions">
<button
class="action"
onclick={handleSync}
disabled={pwaStore.isSyncing}
type="button"
>
{pwaStore.isSyncing ? t.syncing : t.sync_now}
</button>
<button
class="action danger"
onclick={handleClear}
disabled={pwaStore.isSyncing}
type="button"
>
{t.clear_offline_data}
</button>
</div>
{#if pwaStore.isSyncing && pwaStore.syncProgress}
<div class="progress">
<div class="progress-text">
{pwaStore.syncProgress.message}
{#if pwaStore.syncProgress.imageProgress}
· {progressPct}%
{/if}
</div>
{#if pwaStore.syncProgress.imageProgress}
<div class="progress-bar">
<div class="progress-fill" style="width: {progressPct}%"></div>
</div>
{/if}
</div>
{/if}
{#if pwaStore.error}
<div class="error">{pwaStore.error}</div>
{/if}
</div>
{/if}
</div>
{/if}
+99 -16
View File
@@ -5,10 +5,18 @@
import { browser } from '$app/environment';
import LogIn from '@lucide/svelte/icons/log-in';
import { m, type CommonLang } from '$lib/js/commonI18n';
import { pwaStore } from '$lib/stores/pwa.svelte';
let { user, recipeLang = 'rezepte', lang = 'de' } = $props();
const t = $derived(m[lang as CommonLang]);
async function handleSync() {
await pwaStore.syncForOffline();
}
async function handleClear() {
await pwaStore.clearOfflineData();
}
function toggle_options(){
const el = document.querySelector("#options-wrap") as HTMLElement | null;
if (el) el.hidden = !el.hidden;
@@ -16,9 +24,9 @@
onMount( () => {
document.addEventListener("click", (e: MouseEvent) => {
const userButton = document.querySelector("#button");
const userWrap = document.querySelector("#user-wrap");
if(userButton && !userButton.contains(e.target as Node)){
if(userWrap && !userWrap.contains(e.target as Node)){
const wrap = document.querySelector("#options-wrap") as HTMLElement | null;
if (wrap) wrap.hidden = true;
}
@@ -49,8 +57,11 @@
}
</script>
<style>
button {
.user-wrap {
position: relative;
display: inline-flex;
}
button.avatar {
background-color: transparent;
border: none;
width: 1.8rem;
@@ -138,6 +149,39 @@
#options li:hover a {
color: var(--menu-text-hover);
}
#options li button.menu-action {
appearance: none;
background: transparent;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: var(--menu-text);
text-align: left;
cursor: pointer;
transition: var(--transition-fast);
width: auto;
}
#options li:hover button.menu-action:not(:disabled) {
color: var(--menu-text-hover);
}
#options li button.menu-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-section-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
margin-top: 0.6rem;
margin-bottom: 0.15rem;
}
.menu-divider {
height: 1px;
background: var(--menu-border, rgba(255,255,255,0.08));
margin: 0.5rem 0;
}
h2 {
margin-block: 0;
font-size: 1.1rem;
@@ -150,21 +194,60 @@
</style>
{#if user}
<button onclick={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button>
<div class="options-wrap" hidden id=options-wrap>
<div id=options>
<h2>{user.name}</h2>
<p>({user.nickname})</p>
<ul>
{#if user.groups?.includes('rezepte_users')}
<li><a href={resolve('/[recipeLang=recipeLang]/administration', { recipeLang })}>Administration</a></li>
{/if}
<li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
<li><a href={`${resolve('/logout')}?callbackUrl=${encodeURIComponent(getLogoutCallbackUrl(page.url.pathname))}`}>Log Out</a></li>
</ul>
<div class="user-wrap" id=user-wrap>
<button
class="avatar"
onclick={toggle_options}
style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)"
id=button
aria-label={user.name}
></button>
<div class="options-wrap" hidden id=options-wrap>
<div id=options>
<h2>{user.name}</h2>
<p>({user.nickname})</p>
<ul>
{#if user.groups?.includes('rezepte_users')}
<li><a href={resolve('/[recipeLang=recipeLang]/administration', { recipeLang })}>Administration</a></li>
{/if}
<li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
{#if pwaStore.isStandalone}
<li class="menu-divider" aria-hidden="true"></li>
<li class="menu-section-label">{t.offline_data}</li>
{#if pwaStore.isOfflineAvailable}
<li>
<button
class="menu-action"
type="button"
onclick={handleSync}
disabled={pwaStore.isSyncing}
>{pwaStore.isSyncing ? t.syncing : t.sync_now}</button>
</li>
<li>
<button
class="menu-action"
type="button"
onclick={handleClear}
disabled={pwaStore.isSyncing}
>{t.clear_offline_data}</button>
</li>
{:else}
<li>
<button
class="menu-action"
type="button"
onclick={handleSync}
disabled={pwaStore.isSyncing}
>{pwaStore.isSyncing ? t.syncing : t.sync_for_offline}</button>
</li>
{/if}
<li class="menu-divider" aria-hidden="true"></li>
{/if}
<li><a href={`${resolve('/logout')}?callbackUrl=${encodeURIComponent(getLogoutCallbackUrl(page.url.pathname))}`}>Log Out</a></li>
</ul>
</div>
</div>
</div>
</button>
{:else}
<a
class="entry login-link"
+5
View File
@@ -27,6 +27,11 @@ export const de = {
recipes_word: 'Rezepte',
sync_now: 'Jetzt synchronisieren',
clear_offline_data: 'Offline-Daten löschen',
offline_banner_title: 'Rezepte für die Küche speichern',
offline_banner_body: 'Lade alle Rezepte auf dein Gerät, damit sie auch ohne Internet verfügbar sind — ideal beim Kochen.',
offline_banner_action: 'Rezepte herunterladen',
dismiss: 'Ausblenden',
offline_data: 'Offline-Daten',
// Date picker
select_date: 'Datum wählen',
+5
View File
@@ -27,6 +27,11 @@ export const en = {
recipes_word: 'recipes',
sync_now: 'Sync now',
clear_offline_data: 'Clear offline data',
offline_banner_title: 'Save recipes for the kitchen',
offline_banner_body: 'Download every recipe to your device so they stay available without internet — ideal while cooking.',
offline_banner_action: 'Download recipes',
dismiss: 'Dismiss',
offline_data: 'Offline data',
// Date picker
select_date: 'Select date',