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:
@@ -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()}
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user