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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.57.8",
"version": "1.58.0",
"private": true,
"type": "module",
"scripts": {
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Build locally and rsync artifacts to the production server.
# Avoids running pnpm / npm / any git-hosted prepare step on the server.
#
# Assumes:
# - Local machine matches the server's arch + libc (linux-x64-glibc).
# - Local Node major version matches the server's.
# - Root SSH to $REMOTE works (key-based).
#
# Usage: scripts/deploy.sh [--dry-run]
set -euo pipefail
REMOTE="${REMOTE:-root@bocken.org}"
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
SERVICE="${SERVICE:-homepage.service}"
DRY=""
if [[ "${1:-}" == "--dry-run" ]]; then
DRY="--dry-run"
echo ":: DRY RUN — no files will be transferred"
fi
cd "$(dirname "$0")/.."
echo ":: Sanity-checking local/remote toolchain parity"
local_node=$(node --version)
remote_node=$(ssh "$REMOTE" 'node --version')
if [[ "${local_node%%.*}" != "${remote_node%%.*}" ]]; then
echo "!! Node major mismatch: local $local_node vs remote $remote_node"
echo " Native modules (sharp, onnxruntime, bson) may break. Aborting."
exit 1
fi
echo " node $local_node (match)"
echo ":: Installing deps (frozen lockfile)"
pnpm install --frozen-lockfile
echo ":: Building"
pnpm build
if [[ ! -d build ]]; then
echo "!! build/ not produced — aborting"
exit 1
fi
# The server's systemd unit runs from $REMOTE_DIR/dist, so map build → dist.
echo ":: Syncing build/ → $REMOTE:$REMOTE_DIR/dist/"
rsync -az --delete $DRY --info=progress2 \
build/ "$REMOTE:$REMOTE_DIR/dist/"
echo ":: Syncing node_modules/ → $REMOTE:$REMOTE_DIR/node_modules/"
rsync -az --delete $DRY --info=progress2 \
node_modules/ "$REMOTE:$REMOTE_DIR/node_modules/"
echo ":: Syncing static/ → $REMOTE:$REMOTE_DIR/static/"
rsync -az --delete $DRY --info=progress2 \
static/ "$REMOTE:$REMOTE_DIR/static/"
echo ":: Syncing package.json + pnpm-lock.yaml"
rsync -az $DRY \
package.json pnpm-lock.yaml "$REMOTE:$REMOTE_DIR/"
if [[ -n "$DRY" ]]; then
echo ":: Dry run complete — no service restart"
exit 0
fi
echo ":: Fixing ownership on server"
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml"
echo ":: Restarting $SERVICE"
ssh "$REMOTE" "systemctl restart $SERVICE"
echo ":: Verifying service is active"
sleep 2
if ssh "$REMOTE" "systemctl is-active --quiet $SERVICE"; then
echo " $SERVICE is running"
else
echo "!! $SERVICE failed to start — check logs:"
ssh "$REMOTE" "journalctl -u $SERVICE -n 30 --no-pager"
exit 1
fi
echo ":: Deploy complete"
+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',
@@ -45,7 +45,7 @@ onNavigate((navigation) => {
});
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';
import OfflineSyncIndicator from '$lib/components/OfflineSyncIndicator.svelte';
import BookOpen from '@lucide/svelte/icons/book-open';
import Heart from '@lucide/svelte/icons/heart';
import Leaf from '@lucide/svelte/icons/leaf';
@@ -103,10 +103,25 @@ function isActive(path) {
<LanguageSelector lang={data.lang} />
{/snippet}
{#snippet logo_overlay()}
<div class="logo-pip">
<OfflineSyncIndicator lang={data.lang} />
</div>
{/snippet}
{#snippet right_side()}
<OfflineSyncButton lang={data.lang} />
<UserHeader {user} recipeLang={data.recipeLang} lang={data.lang}></UserHeader>
{/snippet}
{@render children()}
</Header>
<style>
:global(.logo-pip) {
position: absolute;
top: -8px;
right: -7px;
z-index: 2;
pointer-events: auto;
}
</style>
@@ -3,6 +3,7 @@
import type { PageData } from './$types';
import AddButton from '$lib/components/AddButton.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import OfflineSyncBanner from '$lib/components/OfflineSyncBanner.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import { getCategories } from '$lib/js/categories';
import { m, type RecipesLang } from '$lib/js/recipesI18n';
@@ -314,6 +315,19 @@
z-index: 10;
}
/* ─── Offline sync banner — between search and recipe grid ─── */
.banner-wrap {
max-width: 1200px;
margin: 0 auto;
padding: 0 2em;
position: relative;
z-index: 9;
}
.banner-wrap.fallback {
padding: 0 2em;
margin: 0 auto;
}
.sentinel {
height: 1px;
}
@@ -427,6 +441,9 @@
<div class="hero-search-wrap">
<Search lang={data.lang} recipes={data.all_brief} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
</div>
<div class="banner-wrap">
<OfflineSyncBanner lang={data.lang} />
</div>
<div class="recipe-grid">
{#each visibleRecipes as recipe, i (recipe._id)}
<CompactCard
@@ -455,6 +472,9 @@
<h1>{labels.title}</h1>
<p class="subheading">{labels.subheading}</p>
</div>
<div class="banner-wrap fallback">
<OfflineSyncBanner lang={data.lang} />
</div>
<Search lang={data.lang} recipes={data.all_brief} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
<div class="recipe-grid">
+6 -6
View File
@@ -9,14 +9,14 @@
"background_color": "#2E3440",
"icons": [
{
"src": "/favicon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/favicon-512.png",
"sizes": "512x512",
"src": "/favicon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}