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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.57.8",
|
||||
"version": "1.58.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Executable
+86
@@ -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,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}>
|
||||
<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()}
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,7 +194,14 @@
|
||||
</style>
|
||||
|
||||
{#if user}
|
||||
<button onclick={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button>
|
||||
<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>
|
||||
@@ -160,11 +211,43 @@
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
class="entry login-link"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user