feat(manacore/web): enhance Times & Zitare pages, add DetailViews, clean up homepage

- Times ListView: inline timer with start/stop, name input, live elapsed display
- Times DetailView: editable entry with duration (h/m/s), project, billable, delete
- Zitare ListView: tap-to-cycle quotes, inline fav button, tag drag-and-drop support
- Zitare DetailView: full quote view with category, author bio, share, favorite
- Homepage: remove tag bar (available via PillNav), carousel uses full width
- Register both DetailViews in app-registry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 11:52:27 +02:00
parent c5906e4c29
commit 1245fdc077
8 changed files with 981 additions and 127 deletions

View file

@ -161,9 +161,9 @@
/* Carousel track */
.fokus-track {
display: flex;
gap: 1.5rem;
gap: 1rem;
overflow-x: auto;
padding: 1rem calc(50% - var(--sheet-width) / 2);
padding: 0.5rem 0;
scrollbar-width: none;
flex: 1;
}

View file

@ -63,12 +63,20 @@ export const APP_REGISTRY: AppEntry[] = [
name: 'Times',
color: '#F59E0B',
load: () => import('$lib/modules/times/ListView.svelte'),
views: {
list: { load: () => import('$lib/modules/times/ListView.svelte') },
detail: { load: () => import('$lib/modules/times/views/DetailView.svelte') },
},
},
{
id: 'zitare',
name: 'Zitare',
color: '#EC4899',
load: () => import('$lib/modules/zitare/ListView.svelte'),
views: {
list: { load: () => import('$lib/modules/zitare/ListView.svelte') },
detail: { load: () => import('$lib/modules/zitare/views/DetailView.svelte') },
},
},
{
id: 'cards',

View file

@ -1,17 +1,36 @@
<!--
Times — Workbench ListView
Today's time entries with running timer and daily total.
Inline timer with start/stop + today's time entries.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { timerStore } from '$lib/modules/times/stores/timer.svelte';
import { formatDuration } from '$lib/modules/times/queries';
import { Play, Stop } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalTimeEntry, LocalProject } from './types';
let { navigate, goBack, params }: ViewProps = $props();
let entries = $state<LocalTimeEntry[]>([]);
let projects = $state<LocalProject[]>([]);
let description = $state('');
const todayStr = new Date().toISOString().split('T')[0];
// Initialize timer store to pick up running timers
$effect(() => {
timerStore.initialize();
});
// Sync description with running entry
$effect(() => {
if (timerStore.runningEntry) {
description = timerStore.runningEntry.description || '';
}
});
$effect(() => {
const sub = liveQuery(async () => {
return db
@ -38,12 +57,10 @@
const todayEntries = $derived(
entries
.filter((e) => e.date === todayStr)
.filter((e) => e.date === todayStr && !e.isRunning)
.sort((a, b) => (b.startTime ?? '').localeCompare(a.startTime ?? ''))
);
const running = $derived(entries.find((e) => e.isRunning));
const totalToday = $derived(todayEntries.reduce((sum, e) => sum + e.duration, 0));
function projectName(projectId?: string | null): string {
@ -51,45 +68,93 @@
return projects.find((p) => p.id === projectId)?.name ?? 'Projekt';
}
function formatDuration(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
function fmtCompact(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
async function handleStartStop() {
if (timerStore.isRunning) {
await timerStore.stop();
description = '';
} else {
await timerStore.start({ description });
}
}
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
function handleDescriptionInput(value: string) {
description = value;
if (!timerStore.isRunning) return;
if (debounceTimeout) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
timerStore.updateRunning({ description: value });
}, 500);
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- Running timer -->
{#if running}
<div class="rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2">
<div class="flex items-center gap-2">
<div class="h-2 w-2 animate-pulse rounded-full bg-green-400"></div>
<p class="text-sm font-medium text-white/80">{running.description || 'Timer läuft'}</p>
<!-- Inline Timer -->
<div class="flex items-center gap-2">
<button
onclick={handleStartStop}
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors {timerStore.isRunning
? 'bg-red-500/80 text-white hover:bg-red-500'
: 'bg-white/10 text-white/50 hover:bg-green-500/80 hover:text-white'}"
>
{#if timerStore.isRunning}
<Stop size={14} weight="fill" />
{:else}
<Play size={14} weight="fill" />
{/if}
</button>
<input
type="text"
value={description}
oninput={(e) => handleDescriptionInput((e.target as HTMLInputElement).value)}
placeholder="Was trackst du?"
class="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs text-white/90 placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
{#if timerStore.isRunning}
<div class="flex h-7 items-center gap-1.5 rounded-full bg-green-500/10 px-2.5">
<div class="h-1.5 w-1.5 animate-pulse rounded-full bg-green-400"></div>
<span class="font-mono text-xs text-green-400">
{formatDuration(timerStore.elapsedSeconds)}
</span>
</div>
<p class="mt-0.5 text-xs text-white/40">{projectName(running.projectId)}</p>
</div>
{/if}
{/if}
</div>
<!-- Today stats -->
<div class="flex items-center justify-between text-xs text-white/40">
<span>Heute: {todayEntries.length} Einträge</span>
<span class="font-medium text-white/60">{formatDuration(totalToday)}</span>
<span>Heute: {todayEntries.length} Eintr{todayEntries.length === 1 ? 'ag' : 'age'}</span>
<span class="font-medium text-white/60">{fmtCompact(totalToday)}</span>
</div>
<!-- Entry list -->
<div class="flex-1 overflow-auto">
{#each todayEntries as entry (entry.id)}
<div class="mb-1 rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<button
onclick={() => navigate('detail', { entryId: entry.id })}
class="mb-1 w-full rounded-md px-3 py-2 text-left transition-colors hover:bg-white/5"
>
<div class="flex items-center justify-between">
<p class="truncate text-sm text-white/80">{entry.description || 'Ohne Beschreibung'}</p>
<span class="shrink-0 text-xs text-white/50">{formatDuration(entry.duration)}</span>
<p class="truncate text-sm text-white/80">
{entry.description || 'Ohne Beschreibung'}
</p>
<span class="shrink-0 text-xs text-white/50">{fmtCompact(entry.duration)}</span>
</div>
<p class="text-xs text-white/30">{projectName(entry.projectId)}</p>
</div>
</button>
{/each}
{#if todayEntries.length === 0 && !running}
<p class="py-8 text-center text-sm text-white/30">Noch keine Zeiteinträge heute</p>
{#if todayEntries.length === 0 && !timerStore.isRunning}
<p class="py-8 text-center text-sm text-white/30">Noch keine Zeiteintr&auml;ge heute</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,524 @@
<!--
Times — DetailView (inline editable time entry overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalTimeEntry, LocalProject, LocalClient } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let entryId = $derived(params.entryId as string);
let entry = $state<LocalTimeEntry | null>(null);
let projects = $state<LocalProject[]>([]);
let clients = $state<LocalClient[]>([]);
let confirmDelete = $state(false);
// Edit fields
let editDescription = $state('');
let editDate = $state('');
let editDurationH = $state(0);
let editDurationM = $state(0);
let editDurationS = $state(0);
let editProjectId = $state<string | null>(null);
let editBillable = $state(false);
let focused = $state(false);
$effect(() => {
entryId;
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalTimeEntry>('timeEntries').get(entryId)).subscribe(
(val) => {
entry = val ?? null;
if (val && !focused) {
editDescription = val.description ?? '';
editDate = val.date ?? '';
const dur = val.duration ?? 0;
editDurationH = Math.floor(dur / 3600);
editDurationM = Math.floor((dur % 3600) / 60);
editDurationS = dur % 60;
editProjectId = val.projectId ?? null;
editBillable = val.isBillable;
}
}
);
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () =>
db
.table<LocalProject>('timesProjects')
.toArray()
.then((all) => all.filter((p) => !p.deletedAt && !p.isArchived))
).subscribe((val) => {
projects = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () =>
db
.table<LocalClient>('timeClients')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt))
).subscribe((val) => {
clients = val ?? [];
});
return () => sub.unsubscribe();
});
let selectedProject = $derived(
editProjectId ? projects.find((p) => p.id === editProjectId) : null
);
let selectedClient = $derived(
selectedProject?.clientId ? clients.find((c) => c.id === selectedProject!.clientId) : null
);
function durationSeconds(): number {
return editDurationH * 3600 + editDurationM * 60 + editDurationS;
}
function fmtTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
}
async function saveField() {
focused = false;
await db.table('timeEntries').update(entryId, {
description: editDescription.trim(),
date: editDate,
duration: durationSeconds(),
projectId: editProjectId,
clientId: selectedProject?.clientId ?? null,
isBillable: editBillable,
});
}
async function handleProjectChange(projectId: string | null) {
editProjectId = projectId;
const project = projectId ? projects.find((p) => p.id === projectId) : null;
if (project) editBillable = project.isBillable;
await db.table('timeEntries').update(entryId, {
projectId: projectId,
clientId: project?.clientId ?? null,
isBillable: project?.isBillable ?? editBillable,
});
}
async function handleBillableChange() {
editBillable = !editBillable;
await db.table('timeEntries').update(entryId, { isBillable: editBillable });
}
async function deleteEntry() {
await db.table('timeEntries').update(entryId, { deletedAt: new Date().toISOString() });
goBack();
}
</script>
<div class="detail-view">
{#if !entry}
<p class="empty">Eintrag nicht gefunden</p>
{:else}
<!-- Description -->
<div class="section">
<input
class="title-input"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Beschreibung..."
/>
</div>
<!-- Time info -->
{#if entry.startTime || entry.endTime}
<div class="time-range">
{#if entry.startTime}{fmtTime(entry.startTime)}{/if}
{#if entry.startTime && entry.endTime}
&ndash;
{/if}
{#if entry.endTime}{fmtTime(entry.endTime)}{/if}
</div>
{/if}
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Datum</span>
<input
type="date"
class="prop-input"
bind:value={editDate}
onfocus={() => (focused = true)}
onblur={saveField}
/>
</div>
<div class="prop-row">
<span class="prop-label">Dauer</span>
<div class="duration-inputs">
<label class="dur-field">
<input
type="number"
min="0"
class="dur-input"
bind:value={editDurationH}
onfocus={() => (focused = true)}
onblur={saveField}
/>
<span class="dur-unit">h</span>
</label>
<label class="dur-field">
<input
type="number"
min="0"
max="59"
class="dur-input"
bind:value={editDurationM}
onfocus={() => (focused = true)}
onblur={saveField}
/>
<span class="dur-unit">m</span>
</label>
<label class="dur-field">
<input
type="number"
min="0"
max="59"
class="dur-input"
bind:value={editDurationS}
onfocus={() => (focused = true)}
onblur={saveField}
/>
<span class="dur-unit">s</span>
</label>
</div>
</div>
<div class="prop-row">
<span class="prop-label">Projekt</span>
<select
class="prop-select"
value={editProjectId ?? ''}
onchange={(e) => {
const val = (e.target as HTMLSelectElement).value;
handleProjectChange(val || null);
}}
>
<option value="">Kein Projekt</option>
{#each projects as project}
<option value={project.id}>
{project.name}
{#if project.clientId}
{@const client = clients.find((c) => c.id === project.clientId)}
{#if client}
&middot; {client.name}{/if}
{/if}
</option>
{/each}
</select>
</div>
<div class="prop-row">
<span class="prop-label">Abrechenbar</span>
<button class="billable-toggle" class:active={editBillable} onclick={handleBillableChange}>
{editBillable ? 'Ja' : 'Nein'}
</button>
</div>
{#if selectedProject}
<div class="prop-row">
<span class="prop-label">Projekt</span>
<div class="flex items-center gap-1.5">
<span class="color-dot" style="background-color: {selectedProject.color}"></span>
<span class="prop-value">{selectedProject.name}</span>
</div>
</div>
{/if}
{#if selectedClient}
<div class="prop-row">
<span class="prop-label">Kunde</span>
<span class="prop-value">{selectedClient.name}</span>
</div>
{/if}
</div>
<!-- Metadata -->
<div class="meta">
{#if entry.createdAt}
<span>Erstellt: {new Date(entry.createdAt).toLocaleDateString('de')}</span>
{/if}
{#if entry.source?.app}
<span>Quelle: {entry.source.app}</span>
{/if}
</div>
<!-- Delete -->
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Eintrag wirklich loschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteEntry}>Loschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Loschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title */
.title-input {
width: 100%;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Time range */
.time-range {
font-size: 0.8125rem;
color: #9ca3af;
font-variant-numeric: tabular-nums;
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.prop-select,
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-select:hover,
.prop-input:hover,
.prop-select:focus,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-select,
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-select:hover,
:global(.dark) .prop-input:hover,
:global(.dark) .prop-select:focus,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Duration inputs */
.duration-inputs {
display: flex;
gap: 0.375rem;
}
.dur-field {
display: flex;
align-items: center;
gap: 0.125rem;
}
.dur-input {
width: 2.5rem;
font-size: 0.8125rem;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
text-align: right;
font-variant-numeric: tabular-nums;
-moz-appearance: textfield;
}
.dur-input::-webkit-outer-spin-button,
.dur-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.dur-input:hover,
.dur-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .dur-input {
color: #e5e7eb;
}
:global(.dark) .dur-input:hover,
:global(.dark) .dur-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.dur-unit {
font-size: 0.6875rem;
color: #9ca3af;
}
/* Billable toggle */
.billable-toggle {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.billable-toggle.active {
border-color: #22c55e;
color: #22c55e;
background: rgba(34, 197, 94, 0.08);
}
:global(.dark) .billable-toggle {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .billable-toggle.active {
border-color: #22c55e;
}
/* Color dot */
.color-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
}
/* Section */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
</style>

View file

@ -1,13 +1,31 @@
<!--
Zitare — Workbench ListView
Quote of the day with favorites count.
Shows one quote at a time. Tap to cycle. Fav button inline.
Supports tag drag-and-drop onto the current quote.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
import { favoritesStore } from '$lib/modules/zitare/stores/favorites.svelte';
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/modules/zitare/queries';
import { Heart } from '@manacore/shared-icons';
import { dropTarget } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { getTagsByIds } from '$lib/stores/tags.svelte';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalFavorite } from './types';
import type { Quote } from '@zitare/content';
let { navigate, goBack, params }: ViewProps = $props();
let favorites = $state<LocalFavorite[]>([]);
let quote = $state<Quote | null>(null);
$effect(() => {
quotesStore.initialize();
quote = quotesStore.currentQuote;
});
$effect(() => {
const sub = liveQuery(async () => {
@ -21,38 +39,95 @@
return () => sub.unsubscribe();
});
// Simple daily quote selection based on day of year
const quotes = [
{ text: 'Der Weg ist das Ziel.', author: 'Konfuzius' },
{ text: 'Wer nicht wagt, der nicht gewinnt.', author: 'Sprichwort' },
{
text: 'In der Mitte von Schwierigkeiten liegen die Möglichkeiten.',
author: 'Albert Einstein',
},
{ text: 'Es ist nicht genug zu wissen, man muss auch anwenden.', author: 'Goethe' },
{ text: 'Handle, ehe du denkst. Nein — denke, ehe du handelst.', author: 'Mark Twain' },
{
text: 'Die Zukunft gehört denen, die an die Schönheit ihrer Träume glauben.',
author: 'Eleanor Roosevelt',
},
{ text: 'Was immer du tun kannst oder träumst es zu können, fang damit an.', author: 'Goethe' },
];
const dayOfYear = Math.floor(
(Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000
let favoritesAsDomain = $derived<Favorite[]>(
favorites.map((f) => ({ id: f.id, quoteId: f.quoteId, createdAt: f.createdAt ?? '' }))
);
const todayQuote = quotes[dayOfYear % quotes.length];
let currentFav = $derived(quote ? favorites.find((f) => f.quoteId === quote!.id) : undefined);
let isFav = $derived(!!currentFav);
let currentTagIds = $derived(currentFav?.tagIds ?? []);
let currentTags = $derived(getTagsByIds(currentTagIds));
function nextQuote() {
quotesStore.loadRandomQuote();
quote = quotesStore.currentQuote;
}
async function toggleFav(e: Event) {
e.stopPropagation();
if (!quote) return;
await favoritesStore.toggle(quote.id, favoritesAsDomain);
}
async function handleTagDrop(tagData: TagDragData) {
if (!quote) return;
// Ensure quote is favorited first
let fav = favorites.find((f) => f.quoteId === quote!.id);
if (!fav) {
await favoritesStore.add(quote.id);
// Re-fetch to get the new favorite
const all = await db.table<LocalFavorite>('zitareFavorites').toArray();
fav = all.find((f) => f.quoteId === quote!.id && !f.deletedAt);
if (!fav) return;
}
const current = fav.tagIds ?? [];
if (!current.includes(tagData.id)) {
await db.table('zitareFavorites').update(fav.id, {
tagIds: [...current, tagData.id],
updatedAt: new Date().toISOString(),
});
}
}
</script>
<div class="flex h-full flex-col items-center justify-center gap-6 p-6">
<div class="text-center">
<blockquote class="text-lg font-light italic leading-relaxed text-white/80">
&laquo;{todayQuote.text}&raquo;
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex h-full cursor-pointer flex-col items-center justify-center p-6"
onclick={nextQuote}
use:dropTarget={{
accepts: ['tag'],
onDrop: (p) => handleTagDrop(p.data as unknown as TagDragData),
canDrop: (p) => !currentTagIds.includes((p.data as unknown as TagDragData).id),
}}
>
{#if quote}
<blockquote
class="max-w-[280px] text-center text-base font-light italic leading-relaxed text-white/80"
>
&laquo;{quotesStore.getText(quote)}&raquo;
</blockquote>
<p class="mt-3 text-sm text-white/40">{todayQuote.author}</p>
</div>
<p class="mt-3 text-xs text-white/40">{quote.author}</p>
<div class="text-xs text-white/30">
{favorites.length} gespeicherte Zitate
</div>
<!-- Tags -->
{#if currentTags.length > 0}
<div class="mt-2 flex flex-wrap justify-center gap-1">
{#each currentTags as tag (tag.id)}
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] text-white/50"
style="background: {tag.color}20; border: 1px solid {tag.color}30"
>
<span class="h-1.5 w-1.5 rounded-full" style="background: {tag.color}"></span>
{tag.name}
</span>
{/each}
</div>
{/if}
<button onclick={toggleFav} class="mt-3 rounded-full p-1.5 transition-colors hover:bg-white/5">
<Heart
size={16}
weight={isFav ? 'fill' : 'regular'}
class="transition-colors {isFav ? 'text-red-400' : 'text-white/20 hover:text-white/40'}"
/>
</button>
{/if}
</div>
<style>
:global(.mana-drop-target-hover) {
outline: 2px solid rgba(139, 92, 246, 0.4);
outline-offset: -2px;
background: rgba(139, 92, 246, 0.06) !important;
}
</style>

View file

@ -6,6 +6,7 @@ import type { BaseRecord } from '@manacore/local-store';
export interface LocalFavorite extends BaseRecord {
quoteId: string;
tagIds?: string[] | null;
}
export interface LocalQuoteList extends BaseRecord {

View file

@ -0,0 +1,250 @@
<!--
Zitare — DetailView
Full quote detail with category, source, author bio, share, favorite.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
import { favoritesStore } from '$lib/modules/zitare/stores/favorites.svelte';
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/modules/zitare/queries';
import { Heart, ShareNetwork, Info } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalFavorite } from '../types';
import { QUOTES, type Quote, type Category } from '@zitare/content';
let { navigate, goBack, params }: ViewProps = $props();
let quoteId = $derived(params.quoteId as string);
let favorites = $state<Favorite[]>([]);
let showBio = $state(false);
let quote = $derived(QUOTES.find((q) => q.id === quoteId) ?? null);
let isFav = $derived(quote ? checkIsFavorite(favorites, quote.id) : false);
let quoteText = $derived(quote ? quotesStore.getText(quote) : '');
const categoryLabels: Record<Category, string> = {
weisheit: 'Weisheit',
motivation: 'Motivation',
liebe: 'Liebe',
leben: 'Leben',
erfolg: 'Erfolg',
glueck: 'Glück',
freundschaft: 'Freundschaft',
mut: 'Mut',
hoffnung: 'Hoffnung',
natur: 'Natur',
};
$effect(() => {
quotesStore.initialize();
});
$effect(() => {
const sub = liveQuery(async () => {
const locals = await db.table<LocalFavorite>('zitareFavorites').toArray();
return locals
.filter((f) => !f.deletedAt)
.map((f) => ({ id: f.id, quoteId: f.quoteId, createdAt: f.createdAt ?? '' }));
}).subscribe((val) => {
favorites = val ?? [];
});
return () => sub.unsubscribe();
});
let authorBio = $derived(() => {
if (!quote?.authorBio) return '';
const lang = quotesStore.language === 'original' ? 'de' : quotesStore.language;
return quote.authorBio[lang] || quote.authorBio.de || '';
});
async function toggleFav() {
if (!quote) return;
await favoritesStore.toggle(quote.id, favorites);
}
async function shareQuote() {
if (!quote) return;
const text = `"${quoteText}" — ${quote.author}`;
if (navigator.share) {
await navigator.share({ text, title: 'Zitare' });
} else {
await navigator.clipboard.writeText(text);
}
}
</script>
<div class="detail-view">
{#if !quote}
<p class="empty">Zitat nicht gefunden</p>
{:else}
<!-- Quote -->
<blockquote class="quote-text">
&ldquo;{quoteText}&rdquo;
</blockquote>
<!-- Author -->
<div class="author-row">
<span class="author-name">{quote.author}</span>
{#if authorBio()}
<button class="bio-btn" onclick={() => (showBio = !showBio)}>
<Info size={14} />
</button>
{/if}
</div>
{#if showBio && authorBio()}
<p class="author-bio">{authorBio()}</p>
{/if}
<!-- Meta -->
<div class="meta-row">
<span class="category-badge">{categoryLabels[quote.category]}</span>
{#if quote.source || quote.year}
<span class="source-text">
{#if quote.source}{quote.source}{/if}
{#if quote.source && quote.year}
&middot;
{/if}
{#if quote.year}{quote.year}{/if}
</span>
{/if}
</div>
<!-- Actions -->
<div class="actions">
<button class="action-btn" class:fav-active={isFav} onclick={toggleFav}>
<Heart size={18} weight={isFav ? 'fill' : 'regular'} />
<span>{isFav ? 'Gespeichert' : 'Speichern'}</span>
</button>
<button class="action-btn" onclick={shareQuote}>
<ShareNetwork size={18} />
<span>Teilen</span>
</button>
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
.quote-text {
font-size: 1.25rem;
font-weight: 300;
font-style: italic;
line-height: 1.7;
color: #374151;
}
:global(.dark) .quote-text {
color: #e5e7eb;
}
.author-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.author-name {
font-size: 0.875rem;
color: #9ca3af;
}
.bio-btn {
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
padding: 0.125rem;
border-radius: 0.25rem;
display: flex;
transition: color 0.15s;
}
.bio-btn:hover {
color: #6b7280;
}
.author-bio {
font-size: 0.8125rem;
font-style: italic;
color: #9ca3af;
line-height: 1.5;
}
.meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.category-badge {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
:global(.dark) .category-badge {
background: rgba(139, 92, 246, 0.15);
}
.source-text {
font-size: 0.75rem;
color: #9ca3af;
}
.actions {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .actions {
border-color: rgba(255, 255, 255, 0.06);
}
.action-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
font-size: 0.75rem;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #6b7280;
}
.action-btn.fav-active {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.2);
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.08);
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
:global(.dark) .action-btn.fav-active {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.2);
}
</style>

View file

@ -4,12 +4,7 @@
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
import { getAppEntry } from '$lib/components/workbench/app-registry';
import { createAppSettingsStore } from '@manacore/shared-stores';
import { DragPreview, dragSource } from '@manacore/shared-ui/dnd';
import { useAllTags } from '$lib/stores/tags.svelte';
// ── Tags for drag & drop ───────────────────────────────
const tagsQuery = useAllTags();
let allTags = $derived(tagsQuery.value ?? []);
import { DragPreview } from '@manacore/shared-ui/dnd';
// ── Persisted workbench state ───────────────────────────
const DEFAULT_WIDTH = 480;
@ -140,23 +135,6 @@
<DragPreview />
<div class="workbench">
{#if allTags.length > 0}
<div class="tag-bar">
{#each allTags as tag (tag.id)}
<button
class="tag-pill"
use:dragSource={{
type: 'tag',
data: () => ({ id: tag.id, name: tag.name, color: tag.color }),
}}
>
<span class="tag-dot" style="background: {tag.color}"></span>
{tag.name}
</button>
{/each}
</div>
{/if}
<PageCarousel
pages={carouselPages}
defaultWidth={DEFAULT_WIDTH}
@ -197,51 +175,4 @@
flex-direction: column;
position: relative;
}
.tag-bar {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.5rem 1rem 0.25rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
border-radius: 9999px;
border: none;
background: rgba(0, 0, 0, 0.04);
font-size: 0.6875rem;
color: #6b7280;
cursor: grab;
transition: all 0.15s;
user-select: none;
touch-action: none;
}
.tag-pill:hover {
background: rgba(0, 0, 0, 0.08);
color: #374151;
}
:global(.dark) .tag-pill {
background: rgba(255, 255, 255, 0.06);
color: #9ca3af;
}
:global(.dark) .tag-pill:hover {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
.tag-pill:active {
cursor: grabbing;
}
:global(.tag-pill.mana-drag-source-active) {
opacity: 0.4;
transform: scale(0.95);
}
.tag-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
</style>