refactor(mana/web): consolidate ListView scaffolding into BaseListView

Every workbench-style module ListView reimplemented the same
liveQuery + filter + scroll-area + empty-state shell. Extract a
shared <BaseListView> in @mana/shared-ui (with toolbar/header/
listHeader/item/empty snippets) and migrate the 17 modules whose
list templates fit the workbench tailwind track.

While here:
- migrate DeckCard onto the existing (previously unused) shared
  Card atom from shared-ui/atoms.
- fix a latent type bug in times/ListView: it was reading .date /
  .startTime / .isRunning off LocalTimeEntry, which doesn't define
  them. Now uses the proper joined TimeEntry via toTimeEntry() like
  the rest of the times module.

Modules with their own scoped-CSS layout track (calendar, finance,
contacts, notes, places, todo, photos, habits, automations, dreams,
cycles) and outliers (calc, events, playground, zitare) are left
alone — migrating them would be a visual rewrite, not a structural
shell swap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 18:40:47 +02:00
parent d941ff2231
commit c3cb9dd533
21 changed files with 878 additions and 1044 deletions

View file

@ -3,41 +3,28 @@
Deck list with card counts and study info.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { LocalDeck, LocalCard } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let decks = $state<LocalDeck[]>([]);
let cards = $state<LocalCard[]>([]);
const decksQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalDeck>('decks').toArray();
return all.filter((d) => !d.deletedAt);
}, [] as LocalDeck[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalDeck>('decks')
.toArray()
.then((all) => all.filter((d) => !d.deletedAt));
}).subscribe((val) => {
decks = val ?? [];
});
return () => sub.unsubscribe();
});
const cardsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalCard>('cards').toArray();
return all.filter((c) => !c.deletedAt);
}, [] as LocalCard[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalCard>('cards')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt));
}).subscribe((val) => {
cards = val ?? [];
});
return () => sub.unsubscribe();
});
const decks = $derived(decksQuery.value);
const cards = $derived(cardsQuery.value);
const dueForReview = $derived(() => {
const dueForReview = $derived.by(() => {
const now = new Date().toISOString();
return cards.filter((c) => c.nextReview && c.nextReview <= now).length;
});
@ -47,36 +34,30 @@
}
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex items-center justify-between">
<p class="text-xs text-white/40">{decks.length} Decks</p>
<p class="text-xs text-amber-400/70">{dueForReview()} fällig</p>
</div>
<BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Decks">
{#snippet header()}
<span class="flex-1">{decks.length} Decks</span>
<span class="text-amber-400/70">{dueForReview} fällig</span>
{/snippet}
<div class="flex-1 overflow-auto">
{#each decks as deck (deck.id)}
<button
onclick={() =>
navigate('detail', {
deckId: deck.id,
_siblingIds: decks.map((d) => d.id),
_siblingKey: 'deckId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded" style="background: {deck.color}"></div>
<p class="flex-1 truncate text-sm font-medium text-white/80">{deck.name}</p>
<span class="text-xs text-white/40">{cardsInDeck(deck.id)}</span>
</div>
{#if deck.description}
<p class="mt-1 truncate text-xs text-white/40">{deck.description}</p>
{/if}
</button>
{/each}
{#if decks.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Decks</p>
{/if}
</div>
</div>
{#snippet item(deck)}
<button
onclick={() =>
navigate('detail', {
deckId: deck.id,
_siblingIds: decks.map((d) => d.id),
_siblingKey: 'deckId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded" style="background: {deck.color}"></div>
<p class="flex-1 truncate text-sm font-medium text-white/80">{deck.name}</p>
<span class="text-xs text-white/40">{cardsInDeck(deck.id)}</span>
</div>
{#if deck.description}
<p class="mt-1 truncate text-xs text-white/40">{deck.description}</p>
{/if}
</button>
{/snippet}
</BaseListView>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { Card } from '@mana/shared-ui';
import type { Deck } from '../types';
interface Props {
@ -17,10 +18,7 @@
}
</script>
<button
class="w-full rounded-xl border border-border bg-card p-4 text-left transition-all hover:border-primary/50 hover:shadow-lg"
{onclick}
>
<Card variant="outlined" interactive {onclick} fullWidth class="text-left">
<div class="space-y-3">
<!-- Color strip -->
<div class="h-1 w-12 rounded-full" style="background: {deck.color}"></div>
@ -43,11 +41,11 @@
<span>{deck.cardCount || 0} Karten</span>
{#if deck.isPublic}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
Offentlich
Öffentlich
</span>
{/if}
</div>
<span>{formatDate(deck.updatedAt)}</span>
</div>
</div>
</button>
</Card>

View file

@ -3,40 +3,31 @@
Recent conversations list.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { LocalConversation, LocalMessage } from './types';
let conversations = $state<LocalConversation[]>([]);
let lastMessages = $state<Map<string, LocalMessage>>(new Map());
const conversationsQuery = useLiveQueryWithDefault(async () => {
const convs = await db.table<LocalConversation>('conversations').toArray();
return convs.filter((c) => !c.deletedAt && !c.isArchived);
}, [] as LocalConversation[]);
$effect(() => {
const sub = liveQuery(async () => {
const convs = await db.table<LocalConversation>('conversations').toArray();
return convs.filter((c) => !c.deletedAt && !c.isArchived);
}).subscribe((val) => {
conversations = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
const msgs = await db.table<LocalMessage>('messages').toArray();
const map = new Map<string, LocalMessage>();
for (const msg of msgs) {
if (msg.deletedAt) continue;
const existing = map.get(msg.conversationId);
if (!existing || (msg.createdAt ?? '') > (existing.createdAt ?? '')) {
map.set(msg.conversationId, msg);
}
const lastMessagesQuery = useLiveQueryWithDefault(async () => {
const msgs = await db.table<LocalMessage>('messages').toArray();
const map = new Map<string, LocalMessage>();
for (const msg of msgs) {
if (msg.deletedAt) continue;
const existing = map.get(msg.conversationId);
if (!existing || (msg.createdAt ?? '') > (existing.createdAt ?? '')) {
map.set(msg.conversationId, msg);
}
return map;
}).subscribe((val) => {
lastMessages = val ?? new Map();
});
return () => sub.unsubscribe();
});
}
return map;
}, new Map<string, LocalMessage>());
const conversations = $derived(conversationsQuery.value);
const lastMessages = $derived(lastMessagesQuery.value);
const sorted = $derived(
[...conversations].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
@ -47,31 +38,27 @@
}
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<p class="text-xs text-white/40">{conversations.length} Unterhaltungen</p>
<BaseListView items={sorted} getKey={(c) => c.id} emptyTitle="Keine Unterhaltungen">
{#snippet header()}
<span>{conversations.length} Unterhaltungen</span>
{/snippet}
<div class="flex-1 overflow-auto">
{#each sorted as conv (conv.id)}
{@const lastMsg = lastMessages.get(conv.id)}
<div class="mb-1 min-h-[44px] rounded-md px-3 py-3 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-white/80">
{conv.title || 'Neue Unterhaltung'}
</p>
{#if conv.isPinned}
<span class="text-[10px] text-white/30">&#128204;</span>
{/if}
</div>
{#if lastMsg}
<p class="mt-0.5 truncate text-xs text-white/40">
{lastMsg.sender === 'user' ? 'Du: ' : ''}{truncate(lastMsg.messageText)}
</p>
{#snippet item(conv)}
{@const lastMsg = lastMessages.get(conv.id)}
<div class="mb-1 min-h-[44px] rounded-md px-3 py-3 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-white/80">
{conv.title || 'Neue Unterhaltung'}
</p>
{#if conv.isPinned}
<span class="text-[10px] text-white/30">&#128204;</span>
{/if}
</div>
{/each}
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Unterhaltungen</p>
{/if}
</div>
</div>
{#if lastMsg}
<p class="mt-0.5 truncate text-xs text-white/40">
{lastMsg.sender === 'user' ? 'Du: ' : ''}{truncate(lastMsg.messageText)}
</p>
{/if}
</div>
{/snippet}
</BaseListView>

View file

@ -3,40 +3,27 @@
Locations list grouped by category with favorites.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { LocalLocation, LocalFavorite } from './types';
import { CATEGORY_COLORS } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let locations = $state<LocalLocation[]>([]);
let favorites = $state<LocalFavorite[]>([]);
const locationsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalLocation>('ccLocations').toArray();
return all.filter((l) => !l.deletedAt);
}, [] as LocalLocation[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalLocation>('ccLocations')
.toArray()
.then((all) => all.filter((l) => !l.deletedAt));
}).subscribe((val) => {
locations = val ?? [];
});
return () => sub.unsubscribe();
});
const favoritesQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalFavorite>('ccFavorites').toArray();
return all.filter((f) => !f.deletedAt);
}, [] as LocalFavorite[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFavorite>('ccFavorites')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {
favorites = val ?? [];
});
return () => sub.unsubscribe();
});
const locations = $derived(locationsQuery.value);
const favorites = $derived(favoritesQuery.value);
const favoriteIds = $derived(new Set(favorites.map((f) => f.locationId)));
@ -55,43 +42,37 @@
};
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={locations} getKey={(l) => l.id} emptyTitle="Keine Orte">
{#snippet header()}
<span>{locations.length} Orte</span>
<span>{favorites.length} Favoriten</span>
</div>
{/snippet}
<div class="flex-1 overflow-auto">
{#each locations as location (location.id)}
<button
onclick={() =>
navigate('detail', {
locationId: location.id,
_siblingIds: locations.map((l) => l.id),
_siblingKey: 'locationId',
})}
class="flex w-full min-h-[44px] items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-white/5 cursor-pointer text-left"
>
<div
class="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-full"
style="background: {CATEGORY_COLORS[location.category] ?? '#666'}"
></div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
<p class="truncate text-sm text-white/80">{location.name}</p>
{#if favoriteIds.has(location.id)}
<span class="text-xs text-yellow-400">&#9733;</span>
{/if}
</div>
<p class="text-xs text-white/40">
{categoryLabels[location.category] ?? location.category}
</p>
{#snippet item(location)}
<button
onclick={() =>
navigate('detail', {
locationId: location.id,
_siblingIds: locations.map((l) => l.id),
_siblingKey: 'locationId',
})}
class="flex w-full min-h-[44px] items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-white/5 cursor-pointer text-left"
>
<div
class="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-full"
style="background: {CATEGORY_COLORS[location.category] ?? '#666'}"
></div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
<p class="truncate text-sm text-white/80">{location.name}</p>
{#if favoriteIds.has(location.id)}
<span class="text-xs text-yellow-400">&#9733;</span>
{/if}
</div>
</button>
{/each}
{#if locations.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Orte</p>
{/if}
</div>
</div>
<p class="text-xs text-white/40">
{categoryLabels[location.category] ?? location.category}
</p>
</div>
</button>
{/snippet}
</BaseListView>

View file

@ -3,36 +3,27 @@
Spaces and recent documents.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import type { LocalContextSpace, LocalDocument } from './types';
let spaces = $state<LocalContextSpace[]>([]);
let documents = $state<LocalDocument[]>([]);
const spacesQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalContextSpace>('contextSpaces').toArray();
return all.filter((s) => !s.deletedAt);
}, [] as LocalContextSpace[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalContextSpace>('contextSpaces')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
spaces = val ?? [];
});
return () => sub.unsubscribe();
});
const documentsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalDocument>('documents').toArray();
const visible = all.filter((d) => !d.deletedAt);
return decryptRecords('documents', visible);
}, [] as LocalDocument[]);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalDocument>('documents').toArray();
const visible = all.filter((d) => !d.deletedAt);
return decryptRecords('documents', visible);
}).subscribe((val) => {
documents = val ?? [];
});
return () => sub.unsubscribe();
});
const spaces = $derived(spacesQuery.value);
const documents = $derived(documentsQuery.value);
const pinnedSpaces = $derived(spaces.filter((s) => s.pinned));
const recentDocs = $derived(
[...documents].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')).slice(0, 15)
@ -45,17 +36,16 @@
};
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={recentDocs} getKey={(d) => d.id} emptyTitle="Keine Dokumente">
{#snippet header()}
<span>{spaces.length} Spaces</span>
<span>{documents.length} Dokumente</span>
</div>
{/snippet}
<div class="flex-1 overflow-auto">
<!-- Pinned spaces -->
{#if spaces.filter((s) => s.pinned).length > 0}
{#snippet listHeader()}
{#if pinnedSpaces.length > 0}
<h3 class="mb-2 text-xs font-medium text-white/50">Angepinnte Spaces</h3>
{#each spaces.filter((s) => s.pinned) as space (space.id)}
{#each pinnedSpaces as space (space.id)}
<div class="mb-1 min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<p class="text-sm font-medium text-white/80">{space.name}</p>
{#if space.description}
@ -64,25 +54,20 @@
</div>
{/each}
{/if}
<!-- Recent documents -->
<h3 class="mb-2 mt-3 text-xs font-medium text-white/50">Zuletzt bearbeitet</h3>
{#each recentDocs as doc (doc.id)}
<div
class="flex min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
>
<span class="text-sm">{@html typeIcons[doc.type] ?? '&#128196;'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/70">{doc.title || 'Unbenannt'}</p>
</div>
{#if doc.pinned}
<span class="text-xs text-white/30">&#128204;</span>
{/if}
</div>
{/each}
{/snippet}
{#if recentDocs.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Dokumente</p>
{/if}
</div>
</div>
{#snippet item(doc)}
<div
class="flex min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
>
<span class="text-sm">{@html typeIcons[doc.type] ?? '&#128196;'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/70">{doc.title || 'Unbenannt'}</p>
</div>
{#if doc.pinned}
<span class="text-xs text-white/30">&#128204;</span>
{/if}
</div>
{/snippet}
</BaseListView>

View file

@ -3,84 +3,62 @@
Collections and items overview.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { ViewProps } from '$lib/app-registry';
import type { LocalCollection, LocalItem } from './types';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let collections = $state<LocalCollection[]>([]);
let items = $state<LocalItem[]>([]);
const collectionsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalCollection>('inventarCollections').orderBy('order').toArray();
return all.filter((c) => !c.deletedAt);
}, [] as LocalCollection[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalCollection>('inventarCollections')
.orderBy('order')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt));
}).subscribe((val) => {
collections = val ?? [];
});
return () => sub.unsubscribe();
});
const itemsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalItem>('inventarItems').toArray();
return all.filter((i) => !i.deletedAt);
}, [] as LocalItem[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalItem>('inventarItems')
.toArray()
.then((all) => all.filter((i) => !i.deletedAt));
}).subscribe((val) => {
items = val ?? [];
});
return () => sub.unsubscribe();
});
const collections = $derived(collectionsQuery.value);
const items = $derived(itemsQuery.value);
function itemsInCollection(collectionId: string): number {
return items.filter((i) => i.collectionId === collectionId).length;
}
const totalValue = $derived(() => {
return items.reduce((sum, i) => sum + (i.purchaseData?.price ?? 0), 0);
});
const totalValue = $derived(items.reduce((sum, i) => sum + (i.purchaseData?.price ?? 0), 0));
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={collections} getKey={(c) => c.id} emptyTitle="Keine Sammlungen">
{#snippet header()}
<span>{items.length} Gegenstände</span>
{#if totalValue() > 0}
<span>~{totalValue().toFixed(0)} EUR</span>
{#if totalValue > 0}
<span>~{totalValue.toFixed(0)} EUR</span>
{/if}
</div>
{/snippet}
<div class="flex-1 overflow-auto">
{#each collections as collection (collection.id)}
<button
onclick={() =>
navigate('detail', {
collectionId: collection.id,
_siblingIds: collections.map((c) => c.id),
_siblingKey: 'collectionId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-center gap-2">
{#if collection.icon}
<span class="text-sm">{collection.icon}</span>
{/if}
<p class="flex-1 truncate text-sm font-medium text-white/80">{collection.name}</p>
<span class="text-xs text-white/40">{itemsInCollection(collection.id)}</span>
</div>
{#if collection.description}
<p class="mt-1 truncate text-xs text-white/30">{collection.description}</p>
{#snippet item(collection)}
<button
onclick={() =>
navigate('detail', {
collectionId: collection.id,
_siblingIds: collections.map((c) => c.id),
_siblingKey: 'collectionId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-center gap-2">
{#if collection.icon}
<span class="text-sm">{collection.icon}</span>
{/if}
</button>
{/each}
{#if collections.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Sammlungen</p>
{/if}
</div>
</div>
<p class="flex-1 truncate text-sm font-medium text-white/80">{collection.name}</p>
<span class="text-xs text-white/40">{itemsInCollection(collection.id)}</span>
</div>
{#if collection.description}
<p class="mt-1 truncate text-xs text-white/30">{collection.description}</p>
{/if}
</button>
{/snippet}
</BaseListView>

View file

@ -3,16 +3,28 @@
Recent memos with transcription status.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { ViewProps } from '$lib/app-registry';
import type { LocalMemo } from './types';
import { memosStore } from './stores/memos.svelte';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let memos = $state<LocalMemo[]>([]);
const memosQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalMemo>('memos').toArray();
return all.filter((m) => !m.deletedAt && !m.isArchived);
}, [] as LocalMemo[]);
const memos = $derived(memosQuery.value);
const sorted = $derived(
[...memos].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
);
const pinned = $derived(memos.filter((m) => m.isPinned));
async function handleVoiceComplete(blob: Blob, durationMs: number) {
const memo = await memosStore.createFromVoice(blob, durationMs, 'de');
@ -24,24 +36,6 @@
});
}
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalMemo>('memos')
.toArray()
.then((all) => all.filter((m) => !m.deletedAt && !m.isArchived));
}).subscribe((val) => {
memos = val ?? [];
});
return () => sub.unsubscribe();
});
const sorted = $derived(
[...memos].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
);
const pinned = $derived(memos.filter((m) => m.isPinned));
function formatDuration(ms: number | null): string {
if (!ms) return '--:--';
const sec = Math.round(ms / 1000);
@ -58,59 +52,54 @@
};
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<VoiceCaptureBar
idleLabel="Memo sprechen"
feature="memoro-voice-capture"
reason="Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
<BaseListView items={sorted} getKey={(m) => m.id} emptyTitle="Keine Memos">
{#snippet toolbar()}
<VoiceCaptureBar
idleLabel="Memo sprechen"
feature="memoro-voice-capture"
reason="Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
{/snippet}
<div class="flex gap-3 text-xs text-white/40">
{#snippet header()}
<span>{memos.length} Memos</span>
<span>{pinned.length} angepinnt</span>
</div>
{/snippet}
<div class="flex-1 overflow-auto">
{#each sorted as memo (memo.id)}
<button
onclick={() =>
navigate('detail', {
memoId: memo.id,
_siblingIds: sorted.map((m) => m.id),
_siblingKey: 'memoId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
{#if memo.isPinned}
<span class="text-xs text-white/30">&#128204;</span>
{/if}
<p class="truncate text-sm font-medium text-white/80">
{memo.title || 'Unbenanntes Memo'}
</p>
</div>
{#if memo.intro}
<p class="mt-0.5 truncate text-xs text-white/40">{memo.intro}</p>
{#snippet item(memo)}
<button
onclick={() =>
navigate('detail', {
memoId: memo.id,
_siblingIds: sorted.map((m) => m.id),
_siblingKey: 'memoId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
{#if memo.isPinned}
<span class="text-xs text-white/30">&#128204;</span>
{/if}
<p class="truncate text-sm font-medium text-white/80">
{memo.title || 'Unbenanntes Memo'}
</p>
</div>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] {statusColors[
memo.processingStatus
] ?? ''}"
>
{memo.processingStatus === 'completed'
? formatDuration(memo.audioDurationMs)
: memo.processingStatus}
</span>
{#if memo.intro}
<p class="mt-0.5 truncate text-xs text-white/40">{memo.intro}</p>
{/if}
</div>
</button>
{/each}
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Memos</p>
{/if}
</div>
</div>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] {statusColors[memo.processingStatus] ??
''}"
>
{memo.processingStatus === 'completed'
? formatDuration(memo.audioDurationMs)
: memo.processingStatus}
</span>
</div>
</button>
{/snippet}
</BaseListView>

View file

@ -3,28 +3,22 @@
Ambient mood selector with color preview.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { LocalMood } from './types';
import { moodsStore } from './stores/moods.svelte';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { Trash, Power } from '@mana/shared-icons';
let moods = $state<LocalMood[]>([]);
const moodsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalMood>('moods').toArray();
return all.filter((m) => !m.deletedAt);
}, [] as LocalMood[]);
const moods = $derived(moodsQuery.value);
let activeMoodId = $state<string | null>(null);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalMood>('moods')
.toArray()
.then((all) => all.filter((m) => !m.deletedAt));
}).subscribe((val) => {
moods = val ?? [];
});
return () => sub.unsubscribe();
});
const activeMood = $derived(moods.find((m) => m.id === activeMoodId));
function gradientStyle(colors: string[]): string {
@ -76,47 +70,46 @@
);
</script>
<div class="flex h-full flex-col gap-4 p-3 sm:p-4">
<!-- Active mood preview -->
{#if activeMood}
<div
class="flex h-24 items-center justify-center rounded-lg"
style={gradientStyle(activeMood.colors)}
>
<p class="text-sm font-medium text-white drop-shadow">{activeMood.name}</p>
</div>
{:else}
<div class="flex h-24 items-center justify-center rounded-lg bg-white/5">
<p class="text-sm text-white/30">Kein Mood aktiv</p>
</div>
{/if}
<!-- Mood grid -->
<div class="flex-1 overflow-auto">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
{#each moods as mood (mood.id)}
<button
onclick={() => (activeMoodId = activeMoodId === mood.id ? null : mood.id)}
oncontextmenu={(e) => handleItemContextMenu(e, mood)}
class="group flex flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-white/5
{activeMoodId === mood.id ? 'ring-1 ring-white/30' : ''}"
>
<div class="h-10 w-10 rounded-full" style={gradientStyle(mood.colors)}></div>
<span class="text-[10px] text-white/50 group-hover:text-white/70">{mood.name}</span>
</button>
{/each}
</div>
{#if moods.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Moods</p>
<BaseListView
items={moods}
getKey={(m) => m.id}
emptyTitle="Keine Moods"
class="gap-4"
listClass="grid grid-cols-2 sm:grid-cols-3 gap-2 content-start"
>
{#snippet toolbar()}
<!-- Active mood preview -->
{#if activeMood}
<div
class="flex h-24 items-center justify-center rounded-lg"
style={gradientStyle(activeMood.colors)}
>
<p class="text-sm font-medium text-white drop-shadow">{activeMood.name}</p>
</div>
{:else}
<div class="flex h-24 items-center justify-center rounded-lg bg-white/5">
<p class="text-sm text-white/30">Kein Mood aktiv</p>
</div>
{/if}
</div>
{/snippet}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, mood: null })}
/>
</div>
{#snippet item(mood)}
<button
onclick={() => (activeMoodId = activeMoodId === mood.id ? null : mood.id)}
oncontextmenu={(e) => handleItemContextMenu(e, mood)}
class="group flex flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-white/5
{activeMoodId === mood.id ? 'ring-1 ring-white/30' : ''}"
>
<div class="h-10 w-10 rounded-full" style={gradientStyle(mood.colors)}></div>
<span class="text-[10px] text-white/50 group-hover:text-white/70">{mood.name}</span>
</button>
{/snippet}
</BaseListView>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, mood: null })}
/>

View file

@ -3,39 +3,28 @@
Song library with recent plays and playlists.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import type { LocalSong, LocalPlaylist } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let songs = $state<LocalSong[]>([]);
let playlists = $state<LocalPlaylist[]>([]);
const songsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalSong>('songs').toArray();
const visible = all.filter((s) => !s.deletedAt);
return decryptRecords('songs', visible);
}, [] as LocalSong[]);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalSong>('songs').toArray();
const visible = all.filter((s) => !s.deletedAt);
return decryptRecords('songs', visible);
}).subscribe((val) => {
songs = val ?? [];
});
return () => sub.unsubscribe();
});
const playlistsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalPlaylist>('playlists').toArray();
return all.filter((p) => !p.deletedAt);
}, [] as LocalPlaylist[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalPlaylist>('playlists')
.toArray()
.then((all) => all.filter((p) => !p.deletedAt));
}).subscribe((val) => {
playlists = val ?? [];
});
return () => sub.unsubscribe();
});
const songs = $derived(songsQuery.value);
const playlists = $derived(playlistsQuery.value);
const recentlyPlayed = $derived(
[...songs]
@ -54,40 +43,37 @@
}
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={recentlyPlayed} getKey={(s) => s.id} emptyTitle="Noch nichts gehört">
{#snippet header()}
<span>{songs.length} Songs</span>
<span>{playlists.length} Playlists</span>
<span>{favorites.length} Favoriten</span>
</div>
{/snippet}
<div class="flex-1 overflow-auto">
{#snippet listHeader()}
<h3 class="mb-2 text-xs font-medium text-white/50">Zuletzt gehört</h3>
{#each recentlyPlayed as song (song.id)}
<button
onclick={() =>
navigate('detail', {
songId: song.id,
_siblingIds: recentlyPlayed.map((s) => s.id),
_siblingKey: 'songId',
})}
class="flex w-full min-h-[44px] items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5 cursor-pointer text-left"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-white/10 text-xs text-white/30"
>
&#9835;
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/80">{song.title}</p>
<p class="truncate text-xs text-white/40">{song.artist ?? 'Unbekannt'}</p>
</div>
<span class="text-xs text-white/30">{formatDuration(song.duration)}</span>
</button>
{/each}
{/snippet}
{#if recentlyPlayed.length === 0}
<p class="py-8 text-center text-sm text-white/30">Noch nichts gehört</p>
{/if}
</div>
</div>
{#snippet item(song)}
<button
onclick={() =>
navigate('detail', {
songId: song.id,
_siblingIds: recentlyPlayed.map((s) => s.id),
_siblingKey: 'songId',
})}
class="flex w-full min-h-[44px] items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5 cursor-pointer text-left"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-white/10 text-xs text-white/30"
>
&#9835;
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/80">{song.title}</p>
<p class="truncate text-xs text-white/40">{song.artist ?? 'Unbekannt'}</p>
</div>
<span class="text-xs text-white/30">{formatDuration(song.duration)}</span>
</button>
{/snippet}
</BaseListView>

View file

@ -3,39 +3,27 @@
Today's nutrition progress with meal log.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import type { LocalMeal, LocalGoal } from './types';
let meals = $state<LocalMeal[]>([]);
let goals = $state<LocalGoal[]>([]);
const mealsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalMeal>('meals').toArray();
const visible = all.filter((m) => !m.deletedAt);
return decryptRecords('meals', visible);
}, [] as LocalMeal[]);
const goalsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalGoal>('nutriphiGoals').toArray();
return all.filter((g) => !g.deletedAt);
}, [] as LocalGoal[]);
const meals = $derived(mealsQuery.value);
const goals = $derived(goalsQuery.value);
const todayStr = new Date().toISOString().split('T')[0];
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalMeal>('meals').toArray();
const visible = all.filter((m) => !m.deletedAt);
return decryptRecords('meals', visible);
}).subscribe((val) => {
meals = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalGoal>('nutriphiGoals')
.toArray()
.then((all) => all.filter((g) => !g.deletedAt));
}).subscribe((val) => {
goals = val ?? [];
});
return () => sub.unsubscribe();
});
const todayMeals = $derived(meals.filter((m) => m.date === todayStr));
const goal = $derived(goals[0]);
@ -58,52 +46,45 @@
};
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<!-- Calorie progress -->
<div class="text-center">
<p class="text-2xl font-light text-white/90">{Math.round(totalCalories)}</p>
<p class="text-xs text-white/40">
<BaseListView items={todayMeals} getKey={(m) => m.id} emptyTitle="Noch keine Mahlzeiten heute">
{#snippet toolbar()}
<!-- Calorie progress -->
<div class="text-center">
<p class="text-2xl font-light text-white/90">{Math.round(totalCalories)}</p>
<p class="text-xs text-white/40">
{#if goal}
von {goal.dailyCalories} kcal
{:else}
kcal heute
{/if}
</p>
{#if goal}
von {goal.dailyCalories} kcal
{:else}
kcal heute
{/if}
</p>
{#if goal}
<div class="mx-auto mt-2 h-1.5 w-32 rounded-full bg-white/10">
<div
class="h-full rounded-full transition-all {calorieProgress >= 100
? 'bg-green-400'
: 'bg-blue-400'}"
style="width: {calorieProgress}%"
></div>
</div>
{/if}
</div>
<!-- Macros -->
<div class="flex justify-center gap-4 text-xs text-white/40">
<span>{Math.round(totalProtein)}g Protein</span>
<span>{todayMeals.length} Mahlzeiten</span>
</div>
<!-- Today's meals -->
<div class="flex-1 overflow-auto">
{#each todayMeals as meal (meal.id)}
<div class="mb-1 min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">{mealTypeLabels[meal.mealType] ?? meal.mealType}</span
>
{#if meal.nutrition}
<span class="text-xs text-white/50">{Math.round(meal.nutrition.calories)} kcal</span>
{/if}
<div class="mx-auto mt-2 h-1.5 w-32 rounded-full bg-white/10">
<div
class="h-full rounded-full transition-all {calorieProgress >= 100
? 'bg-green-400'
: 'bg-blue-400'}"
style="width: {calorieProgress}%"
></div>
</div>
<p class="truncate text-sm text-white/70">{meal.description}</p>
</div>
{/each}
{/if}
</div>
{/snippet}
{#if todayMeals.length === 0}
<p class="py-8 text-center text-sm text-white/30">Noch keine Mahlzeiten heute</p>
{/if}
</div>
</div>
{#snippet header()}
<span class="mx-auto">{Math.round(totalProtein)}g Protein · {todayMeals.length} Mahlzeiten</span
>
{/snippet}
{#snippet item(meal)}
<div class="mb-1 min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">{mealTypeLabels[meal.mealType] ?? meal.mealType}</span>
{#if meal.nutrition}
<span class="text-xs text-white/50">{Math.round(meal.nutrition.calories)} kcal</span>
{/if}
</div>
<p class="truncate text-sm text-white/70">{meal.description}</p>
</div>
{/snippet}
</BaseListView>

View file

@ -3,23 +3,19 @@
Recent images grid with favorites.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import type { LocalImage } from './types';
let images = $state<LocalImage[]>([]);
const imagesQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalImage>('images').toArray();
const visible = all.filter((i) => !i.deletedAt && !i.isArchived);
return decryptRecords('images', visible);
}, [] as LocalImage[]);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalImage>('images').toArray();
const visible = all.filter((i) => !i.deletedAt && !i.isArchived);
return decryptRecords('images', visible);
}).subscribe((val) => {
images = val ?? [];
});
return () => sub.unsubscribe();
});
const images = $derived(imagesQuery.value);
const sorted = $derived(
[...images].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')).slice(0, 20)
@ -28,37 +24,34 @@
const favoriteCount = $derived(images.filter((i) => i.isFavorite).length);
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex items-center justify-between">
<p class="text-xs text-white/40">{images.length} Bilder</p>
<p class="text-xs text-white/40">{favoriteCount} Favoriten</p>
</div>
<BaseListView
items={sorted}
getKey={(i) => i.id}
emptyTitle="Keine Bilder"
listClass="grid grid-cols-2 sm:grid-cols-3 gap-1.5 content-start"
>
{#snippet header()}
<span class="flex-1">{images.length} Bilder</span>
<span>{favoriteCount} Favoriten</span>
{/snippet}
<div class="flex-1 overflow-auto">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
{#each sorted as image (image.id)}
<div class="group relative aspect-square overflow-hidden rounded-md bg-white/5">
{#if image.publicUrl}
<img
src={image.publicUrl}
alt={image.prompt}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<div class="flex h-full items-center justify-center text-white/20 text-xs">
{image.format ?? 'img'}
</div>
{/if}
{#if image.isFavorite}
<span class="absolute right-1 top-1 text-xs text-yellow-400">&#9733;</span>
{/if}
{#snippet item(image)}
<div class="group relative aspect-square overflow-hidden rounded-md bg-white/5">
{#if image.publicUrl}
<img
src={image.publicUrl}
alt={image.prompt}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<div class="flex h-full items-center justify-center text-white/20 text-xs">
{image.format ?? 'img'}
</div>
{/each}
{/if}
{#if image.isFavorite}
<span class="absolute right-1 top-1 text-xs text-yellow-400">&#9733;</span>
{/if}
</div>
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Bilder</p>
{/if}
</div>
</div>
{/snippet}
</BaseListView>

View file

@ -3,39 +3,26 @@
Plant overview with watering schedule.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { ViewProps } from '$lib/app-registry';
import type { LocalPlant, LocalWateringSchedule } from './types';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let plants = $state<LocalPlant[]>([]);
let schedules = $state<LocalWateringSchedule[]>([]);
const plantsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalPlant>('plants').toArray();
return all.filter((p) => !p.deletedAt && p.isActive);
}, [] as LocalPlant[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalPlant>('plants')
.toArray()
.then((all) => all.filter((p) => !p.deletedAt && p.isActive));
}).subscribe((val) => {
plants = val ?? [];
});
return () => sub.unsubscribe();
});
const schedulesQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalWateringSchedule>('wateringSchedules').toArray();
return all.filter((s) => !s.deletedAt);
}, [] as LocalWateringSchedule[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalWateringSchedule>('wateringSchedules')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
schedules = val ?? [];
});
return () => sub.unsubscribe();
});
const plants = $derived(plantsQuery.value);
const schedules = $derived(schedulesQuery.value);
function getSchedule(plantId: string): LocalWateringSchedule | undefined {
return schedules.find((s) => s.plantId === plantId);
@ -58,8 +45,8 @@
};
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={plants} getKey={(p) => p.id} emptyTitle="Keine Pflanzen">
{#snippet header()}
<span>{plants.length} Pflanzen</span>
{#if dueForWatering.length > 0}
<span class="text-blue-400">{dueForWatering.length} giessen</span>
@ -67,45 +54,39 @@
{#if needsAttention.length > 0}
<span class="text-amber-400">{needsAttention.length} brauchen Pflege</span>
{/if}
</div>
{/snippet}
<div class="flex-1 overflow-auto">
{#each plants as plant (plant.id)}
{@const schedule = getSchedule(plant.id)}
{@const waterDue = needsWater(schedule)}
<button
onclick={() =>
navigate('detail', {
plantId: plant.id,
_siblingIds: plants.map((p) => p.id),
_siblingKey: 'plantId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-center gap-2">
<span class="text-sm"
>{@html healthIcons[plant.healthStatus ?? 'healthy'] ?? '&#127793;'}</span
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-white/80">{plant.name}</p>
{#if plant.scientificName}
<p class="truncate text-xs italic text-white/30">{plant.scientificName}</p>
{/if}
</div>
{#if waterDue}
<span class="text-xs text-blue-400">&#128167;</span>
{#snippet item(plant)}
{@const schedule = getSchedule(plant.id)}
{@const waterDue = needsWater(schedule)}
<button
onclick={() =>
navigate('detail', {
plantId: plant.id,
_siblingIds: plants.map((p) => p.id),
_siblingKey: 'plantId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-center gap-2">
<span class="text-sm">
{@html healthIcons[plant.healthStatus ?? 'healthy'] ?? '&#127793;'}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-white/80">{plant.name}</p>
{#if plant.scientificName}
<p class="truncate text-xs italic text-white/30">{plant.scientificName}</p>
{/if}
</div>
{#if schedule}
<p class="mt-1 text-xs text-white/30">
Alle {schedule.frequencyDays} Tage giessen
</p>
{#if waterDue}
<span class="text-xs text-blue-400">&#128167;</span>
{/if}
</button>
{/each}
{#if plants.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Pflanzen</p>
{/if}
</div>
</div>
</div>
{#if schedule}
<p class="mt-1 text-xs text-white/30">
Alle {schedule.frequencyDays} Tage giessen
</p>
{/if}
</button>
{/snippet}
</BaseListView>

View file

@ -3,74 +3,57 @@
Presentation decks list with slide count.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { LocalDeck, LocalSlide } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let decks = $state<LocalDeck[]>([]);
let slides = $state<LocalSlide[]>([]);
const decksQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalDeck>('presiDecks').toArray();
return all.filter((d) => !d.deletedAt);
}, [] as LocalDeck[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalDeck>('presiDecks')
.toArray()
.then((all) => all.filter((d) => !d.deletedAt));
}).subscribe((val) => {
decks = val ?? [];
});
return () => sub.unsubscribe();
});
const slidesQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalSlide>('slides').toArray();
return all.filter((s) => !s.deletedAt);
}, [] as LocalSlide[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalSlide>('slides')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
slides = val ?? [];
});
return () => sub.unsubscribe();
});
const decks = $derived(decksQuery.value);
const slides = $derived(slidesQuery.value);
function slideCount(deckId: string): number {
return slides.filter((s) => s.deckId === deckId).length;
}
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<p class="text-xs text-white/40">{decks.length} Präsentationen</p>
<BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Präsentationen">
{#snippet header()}
<span>{decks.length} Präsentationen</span>
{/snippet}
<div class="flex-1 overflow-auto">
{#each decks as deck (deck.id)}
<button
onclick={() =>
navigate('detail', {
deckId: deck.id,
_siblingIds: decks.map((d) => d.id),
_siblingKey: 'deckId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<p class="truncate text-sm font-medium text-white/80">{deck.title}</p>
<div class="mt-1 flex items-center gap-2 text-xs text-white/40">
<span>{slideCount(deck.id)} Folien</span>
{#if deck.isPublic}
<span class="rounded bg-white/10 px-1.5 py-0.5 text-[10px]">Öffentlich</span>
{/if}
</div>
{#if deck.description}
<p class="mt-1 truncate text-xs text-white/30">{deck.description}</p>
{#snippet item(deck)}
<button
onclick={() =>
navigate('detail', {
deckId: deck.id,
_siblingIds: decks.map((d) => d.id),
_siblingKey: 'deckId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<p class="truncate text-sm font-medium text-white/80">{deck.title}</p>
<div class="mt-1 flex items-center gap-2 text-xs text-white/40">
<span>{slideCount(deck.id)} Folien</span>
{#if deck.isPublic}
<span class="rounded bg-white/10 px-1.5 py-0.5 text-[10px]">Öffentlich</span>
{/if}
</button>
{/each}
{#if decks.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Präsentationen</p>
{/if}
</div>
</div>
</div>
{#if deck.description}
<p class="mt-1 truncate text-xs text-white/30">{deck.description}</p>
{/if}
</button>
{/snippet}
</BaseListView>

View file

@ -3,39 +3,28 @@
Research questions list with status badges.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import type { LocalQuestion, LocalCollection } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let questions = $state<LocalQuestion[]>([]);
let collections = $state<LocalCollection[]>([]);
const questionsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalQuestion>('questions').toArray();
const visible = all.filter((q) => !q.deletedAt);
return decryptRecords('questions', visible);
}, [] as LocalQuestion[]);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalQuestion>('questions').toArray();
const visible = all.filter((q) => !q.deletedAt);
return decryptRecords('questions', visible);
}).subscribe((val) => {
questions = val ?? [];
});
return () => sub.unsubscribe();
});
const collectionsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalCollection>('questionCollections').toArray();
return all.filter((c) => !c.deletedAt);
}, [] as LocalCollection[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalCollection>('questionCollections')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt));
}).subscribe((val) => {
collections = val ?? [];
});
return () => sub.unsubscribe();
});
const questions = $derived(questionsQuery.value);
const collections = $derived(collectionsQuery.value);
const statusColors: Record<string, string> = {
open: 'bg-blue-500/20 text-blue-300',
@ -58,46 +47,40 @@
);
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={sorted} getKey={(q) => q.id} emptyTitle="Keine offenen Fragen">
{#snippet header()}
<span>{questions.length} Fragen</span>
<span>{collections.length} Sammlungen</span>
</div>
{/snippet}
<div class="flex-1 overflow-auto">
{#each sorted as question (question.id)}
<button
onclick={() =>
navigate('detail', {
questionId: question.id,
_siblingIds: sorted.map((q) => q.id),
_siblingKey: 'questionId',
})}
class="mb-2 w-full text-left rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5 cursor-pointer min-h-[44px]"
>
<div class="flex items-start justify-between gap-2">
<p class="text-sm font-medium text-white/80">{question.title}</p>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] {statusColors[question.status] ?? ''}"
>
{statusLabels[question.status] ?? question.status}
</span>
{#snippet item(question)}
<button
onclick={() =>
navigate('detail', {
questionId: question.id,
_siblingIds: sorted.map((q) => q.id),
_siblingKey: 'questionId',
})}
class="mb-2 w-full text-left rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5 cursor-pointer min-h-[44px]"
>
<div class="flex items-start justify-between gap-2">
<p class="text-sm font-medium text-white/80">{question.title}</p>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] {statusColors[question.status] ?? ''}"
>
{statusLabels[question.status] ?? question.status}
</span>
</div>
{#if question.description}
<p class="mt-1 truncate text-xs text-white/30">{question.description}</p>
{/if}
{#if question.tags.length > 0}
<div class="mt-1 flex gap-1">
{#each question.tags.slice(0, 3) as tag}
<span class="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-white/40">{tag}</span>
{/each}
</div>
{#if question.description}
<p class="mt-1 truncate text-xs text-white/30">{question.description}</p>
{/if}
{#if question.tags.length > 0}
<div class="mt-1 flex gap-1">
{#each question.tags.slice(0, 3) as tag}
<span class="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-white/40">{tag}</span>
{/each}
</div>
{/if}
</button>
{/each}
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine offenen Fragen</p>
{/if}
</div>
</div>
{/if}
</button>
{/snippet}
</BaseListView>

View file

@ -3,88 +3,61 @@
Skills overview with XP and levels.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { ViewProps } from '$lib/app-registry';
import type { LocalSkill, LocalActivity } from './types';
import type { LocalSkill } from './types';
import { LEVEL_NAMES, BRANCH_INFO, xpProgress, type SkillBranch } from './types';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let skills = $state<LocalSkill[]>([]);
let activities = $state<LocalActivity[]>([]);
const skillsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalSkill>('skills').toArray();
return all.filter((s) => !s.deletedAt);
}, [] as LocalSkill[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalSkill>('skills')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
skills = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalActivity>('activities')
.toArray()
.then((all) => all.filter((a) => !a.deletedAt));
}).subscribe((val) => {
activities = val ?? [];
});
return () => sub.unsubscribe();
});
const skills = $derived(skillsQuery.value);
const totalXp = $derived(skills.reduce((sum, s) => sum + s.totalXp, 0));
const highestLevel = $derived(Math.max(0, ...skills.map((s) => s.level)));
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<!-- Stats -->
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={skills} getKey={(s) => s.id} emptyTitle="Keine Skills angelegt">
{#snippet header()}
<span>{totalXp} XP</span>
<span>Level {highestLevel}</span>
<span>{skills.length} Skills</span>
</div>
{/snippet}
<!-- Skills list -->
<div class="flex-1 overflow-auto">
{#each skills as skill (skill.id)}
{@const branch = BRANCH_INFO[skill.branch as SkillBranch]}
{@const progress = xpProgress(skill.currentXp, skill.level)}
<button
onclick={() =>
navigate('detail', {
skillId: skill.id,
_siblingIds: skills.map((s) => s.id),
_siblingKey: 'skillId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm">{skill.icon}</span>
<p class="text-sm font-medium text-white/80">{skill.name}</p>
</div>
<span class="text-xs text-white/50">Lv. {skill.level}</span>
{#snippet item(skill)}
{@const branch = BRANCH_INFO[skill.branch as SkillBranch]}
{@const progress = xpProgress(skill.currentXp, skill.level)}
<button
onclick={() =>
navigate('detail', {
skillId: skill.id,
_siblingIds: skills.map((s) => s.id),
_siblingKey: 'skillId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm">{skill.icon}</span>
<p class="text-sm font-medium text-white/80">{skill.name}</p>
</div>
<div class="mt-1 flex items-center gap-2">
<div class="h-1 flex-1 rounded-full bg-white/10">
<div class="h-full rounded-full bg-white/30" style="width: {progress}%"></div>
</div>
<span class="text-[10px] text-white/30">{skill.currentXp} XP</span>
<span class="text-xs text-white/50">Lv. {skill.level}</span>
</div>
<div class="mt-1 flex items-center gap-2">
<div class="h-1 flex-1 rounded-full bg-white/10">
<div class="h-full rounded-full bg-white/30" style="width: {progress}%"></div>
</div>
<p class="mt-0.5 text-[10px] text-white/30">
{branch?.name ?? skill.branch}{LEVEL_NAMES[skill.level] ?? 'Unbekannt'}
</p>
</button>
{/each}
{#if skills.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Skills angelegt</p>
{/if}
</div>
</div>
<span class="text-[10px] text-white/30">{skill.currentXp} XP</span>
</div>
<p class="mt-0.5 text-[10px] text-white/30">
{branch?.name ?? skill.branch}{LEVEL_NAMES[skill.level] ?? 'Unbekannt'}
</p>
</button>
{/snippet}
</BaseListView>

View file

@ -3,39 +3,30 @@
File browser with recent files and folders.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import type { LocalFile, LocalFolder } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let files = $state<LocalFile[]>([]);
let folders = $state<LocalFolder[]>([]);
const filesQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalFile>('files').toArray();
const visible = all.filter((f) => !f.deletedAt && !f.isDeleted);
return decryptRecords('files', visible);
}, [] as LocalFile[]);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalFile>('files').toArray();
const visible = all.filter((f) => !f.deletedAt && !f.isDeleted);
return decryptRecords('files', visible);
}).subscribe((val) => {
files = val ?? [];
});
return () => sub.unsubscribe();
});
const foldersQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalFolder>('folders').toArray();
return all.filter((f) => !f.deletedAt && !f.isDeleted);
}, [] as LocalFolder[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFolder>('folders')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt && !f.isDeleted));
}).subscribe((val) => {
folders = val ?? [];
});
return () => sub.unsubscribe();
});
const files = $derived(filesQuery.value);
const folders = $derived(foldersQuery.value);
const rootFolders = $derived(folders.filter((f) => !f.parentFolderId));
const recentFiles = $derived(
[...files].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')).slice(0, 15)
@ -56,17 +47,16 @@
}
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={recentFiles} getKey={(f) => f.id} emptyTitle="Keine Dateien">
{#snippet header()}
<span>{folders.length} Ordner</span>
<span>{files.length} Dateien</span>
</div>
{/snippet}
<div class="flex-1 overflow-auto">
<!-- Root folders -->
{#if folders.filter((f) => !f.parentFolderId).length > 0}
{#snippet listHeader()}
{#if rootFolders.length > 0}
<h3 class="mb-2 text-xs font-medium text-white/50">Ordner</h3>
{#each folders.filter((f) => !f.parentFolderId) as folder (folder.id)}
{#each rootFolders as folder (folder.id)}
<div
class="flex min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
>
@ -75,27 +65,22 @@
</div>
{/each}
{/if}
<!-- Recent files -->
<h3 class="mb-2 mt-3 text-xs font-medium text-white/50">Zuletzt</h3>
{#each recentFiles as file (file.id)}
<button
onclick={() =>
navigate('detail', {
fileId: file.id,
_siblingIds: recentFiles.map((f) => f.id),
_siblingKey: 'fileId',
})}
class="flex w-full min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-white/5"
>
<span class="text-sm">{@html fileIcon(file.mimeType)}</span>
<span class="min-w-0 flex-1 truncate text-sm text-white/70">{file.name}</span>
<span class="shrink-0 text-xs text-white/30">{formatSize(file.size)}</span>
</button>
{/each}
{/snippet}
{#if recentFiles.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Dateien</p>
{/if}
</div>
</div>
{#snippet item(file)}
<button
onclick={() =>
navigate('detail', {
fileId: file.id,
_siblingIds: recentFiles.map((f) => f.id),
_siblingKey: 'fileId',
})}
class="flex w-full min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-white/5"
>
<span class="text-sm">{@html fileIcon(file.mimeType)}</span>
<span class="min-w-0 flex-1 truncate text-sm text-white/70">{file.name}</span>
<span class="shrink-0 text-xs text-white/30">{formatSize(file.size)}</span>
</button>
{/snippet}
</BaseListView>

View file

@ -3,18 +3,18 @@
Inline timer with start/stop + today's time entries.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { timerStore } from '$lib/modules/times/stores/timer.svelte';
import { formatDuration } from '$lib/modules/times/queries';
import { formatDuration, toTimeEntry } from '$lib/modules/times/queries';
import { BaseListView } from '@mana/shared-ui';
import { Play, Stop } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalTimeEntry, LocalProject } from './types';
import type { LocalTimeEntry, LocalProject, TimeEntry } from './types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let entries = $state<LocalTimeEntry[]>([]);
let projects = $state<LocalProject[]>([]);
let description = $state('');
const todayStr = new Date().toISOString().split('T')[0];
@ -31,29 +31,25 @@
}
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalTimeEntry>('timeEntries')
.toArray()
.then((all) => all.filter((e) => !e.deletedAt));
}).subscribe((val) => {
entries = val ?? [];
});
return () => sub.unsubscribe();
});
const entriesQuery = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTimeEntry>('timeEntries').toArray();
const active = locals.filter((e) => !e.deletedAt);
const blockIds = active.map((e) => e.timeBlockId).filter(Boolean);
const blocks =
blockIds.length > 0
? await db.table<LocalTimeBlock>('timeBlocks').where('id').anyOf(blockIds).toArray()
: [];
const blocksById = new Map(blocks.map((b) => [b.id, b]));
return active.map((e) => toTimeEntry(e, blocksById.get(e.timeBlockId)));
}, [] as TimeEntry[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalProject>('timesProjects')
.toArray()
.then((all) => all.filter((p) => !p.deletedAt));
}).subscribe((val) => {
projects = val ?? [];
});
return () => sub.unsubscribe();
});
const projectsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalProject>('timesProjects').toArray();
return all.filter((p) => !p.deletedAt);
}, [] as LocalProject[]);
const entries = $derived(entriesQuery.value);
const projects = $derived(projectsQuery.value);
const todayEntries = $derived(
entries
@ -98,63 +94,59 @@
}
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<!-- 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>
{/if}
</div>
<!-- Today stats -->
<div class="flex items-center justify-between text-xs text-white/40">
<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)}
<BaseListView items={todayEntries} getKey={(e) => e.id} emptyTitle="Noch keine Zeiteinträge heute">
{#snippet toolbar()}
<!-- Inline Timer -->
<div class="flex items-center gap-2">
<button
onclick={() => navigate('detail', { entryId: entry.id })}
class="mb-1 w-full min-h-[44px] rounded-md px-3 py-2 text-left transition-colors hover:bg-white/5"
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'}"
>
<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">{fmtCompact(entry.duration)}</span>
</div>
<p class="text-xs text-white/30">{projectName(entry.projectId)}</p>
{#if timerStore.isRunning}
<Stop size={14} weight="fill" />
{:else}
<Play size={14} weight="fill" />
{/if}
</button>
{/each}
<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>
{/if}
</div>
{/snippet}
{#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>
{#snippet header()}
<span class="flex-1"
>Heute: {todayEntries.length} Eintr{todayEntries.length === 1 ? 'ag' : 'age'}</span
>
<span class="font-medium text-white/60">{fmtCompact(totalToday)}</span>
{/snippet}
{#snippet item(entry)}
<button
onclick={() => navigate('detail', { entryId: entry.id })}
class="mb-1 w-full min-h-[44px] 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">{fmtCompact(entry.duration)}</span>
</div>
<p class="text-xs text-white/30">{projectName(entry.projectId)}</p>
</button>
{/snippet}
</BaseListView>

View file

@ -3,39 +3,28 @@
Short links list with click counts.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import type { LocalLink, LocalFolder } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let { navigate }: ViewProps = $props();
let links = $state<LocalLink[]>([]);
let folders = $state<LocalFolder[]>([]);
const linksQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalLink>('links').toArray();
const visible = all.filter((l) => !l.deletedAt && l.isActive);
return decryptRecords('links', visible);
}, [] as LocalLink[]);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalLink>('links').toArray();
const visible = all.filter((l) => !l.deletedAt && l.isActive);
return decryptRecords('links', visible);
}).subscribe((val) => {
links = val ?? [];
});
return () => sub.unsubscribe();
});
const foldersQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalFolder>('uloadFolders').toArray();
return all.filter((f) => !f.deletedAt);
}, [] as LocalFolder[]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFolder>('uloadFolders')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {
folders = val ?? [];
});
return () => sub.unsubscribe();
});
const links = $derived(linksQuery.value);
const folders = $derived(foldersQuery.value);
const totalClicks = $derived(links.reduce((sum, l) => sum + l.clickCount, 0));
@ -52,39 +41,33 @@
}
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<div class="flex gap-3 text-xs text-white/40">
<BaseListView items={sorted} getKey={(l) => l.id} emptyTitle="Keine Links">
{#snippet header()}
<span>{links.length} Links</span>
<span>{totalClicks} Klicks</span>
<span>{folders.length} Ordner</span>
</div>
{/snippet}
<div class="flex-1 overflow-auto">
{#each sorted as link (link.id)}
<button
onclick={() =>
navigate('detail', {
linkId: link.id,
_siblingIds: sorted.map((l) => l.id),
_siblingKey: 'linkId',
})}
class="mb-1 w-full min-h-[44px] text-left rounded-md px-3 py-2 transition-colors hover:bg-white/5 cursor-pointer"
>
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-white/80">
{link.title || link.shortCode}
</p>
<span class="shrink-0 text-xs text-white/40">{link.clickCount}</span>
</div>
<p class="truncate text-xs text-white/30">{hostname(link.originalUrl)}</p>
{#if link.customCode}
<p class="text-xs text-blue-400/60">/{link.customCode}</p>
{/if}
</button>
{/each}
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Links</p>
{/if}
</div>
</div>
{#snippet item(link)}
<button
onclick={() =>
navigate('detail', {
linkId: link.id,
_siblingIds: sorted.map((l) => l.id),
_siblingKey: 'linkId',
})}
class="mb-1 w-full min-h-[44px] text-left rounded-md px-3 py-2 transition-colors hover:bg-white/5 cursor-pointer"
>
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-white/80">
{link.title || link.shortCode}
</p>
<span class="shrink-0 text-xs text-white/40">{link.clickCount}</span>
</div>
<p class="truncate text-xs text-white/30">{hostname(link.originalUrl)}</p>
{#if link.customCode}
<p class="text-xs text-blue-400/60">/{link.customCode}</p>
{/if}
</button>
{/snippet}
</BaseListView>

View file

@ -67,7 +67,7 @@ export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './mol
export { ConfirmationPopover } from './molecules';
// Organisms
export { Modal, ConfirmationModal, FormModal, AppSlider } from './organisms';
export { Modal, ConfirmationModal, FormModal, AppSlider, BaseListView } from './organisms';
export type { AppItem } from './organisms';
// Network Graph

View file

@ -0,0 +1,101 @@
<script lang="ts" generics="T">
/**
* BaseListView — shared scaffolding for module ListView components.
*
* Encodes the workbench convention every Mana module's ListView shares:
* wrapper padding → optional stats header → scrollable item region → empty state.
*
* Per-item rendering and data fetching stay with the consumer:
* - Pass `items` (already filtered & decrypted via queries.ts).
* - Provide an `item` snippet that renders one row.
* - Provide an optional `header` snippet for stat counts or filters.
*
* @example
* ```svelte
* <BaseListView items={sorted} getKey={(q) => q.id} emptyTitle="Keine Fragen">
* {#snippet header()}
* <span>{questions.length} Fragen</span>
* {/snippet}
* {#snippet item(question)}
* <button onclick={() => navigate('detail', { id: question.id })}>
* {question.title}
* </button>
* {/snippet}
* </BaseListView>
* ```
*/
import type { Snippet } from 'svelte';
import { EmptyState } from '../molecules';
interface Props<TItem> {
/** Items to render. Should already be filtered (deletedAt) and decrypted. */
items: TItem[];
/** Stable key extractor for the {#each} block. */
getKey: (item: TItem) => string | number;
/** Snippet that renders a single item row. */
item: Snippet<[TItem, number]>;
/** Optional header snippet (e.g. stat counts, filters). */
header?: Snippet;
/** Optional snippet rendered above the items but inside the scroll area. */
listHeader?: Snippet;
/** Optional snippet rendered at the very top, outside the scroll area (toolbar, voice bar, ...). */
toolbar?: Snippet;
/** Empty-state title. */
emptyTitle?: string;
/** Empty-state message. */
emptyMessage?: string;
/** Custom empty-state icon snippet. */
emptyIcon?: Snippet;
/** Override the entire empty area. */
empty?: Snippet;
/** Optional outer class override. */
class?: string;
/** Optional class for the inner scroll/list area. Use this to switch to grid, etc. */
listClass?: string;
}
let {
items,
getKey,
item,
header,
listHeader,
toolbar,
emptyTitle = 'Nichts hier',
emptyMessage,
emptyIcon,
empty,
class: className = '',
listClass = '',
}: Props<T> = $props();
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4 {className}">
{#if toolbar}
{@render toolbar()}
{/if}
{#if header}
<div class="flex gap-3 text-xs text-white/40">
{@render header()}
</div>
{/if}
<div class="flex-1 overflow-auto {listClass}">
{#if listHeader}
{@render listHeader()}
{/if}
{#each items as entry, i (getKey(entry))}
{@render item(entry, i)}
{/each}
{#if items.length === 0}
{#if empty}
{@render empty()}
{:else}
<EmptyState variant="compact" title={emptyTitle} message={emptyMessage} icon={emptyIcon} />
{/if}
{/if}
</div>
</div>

View file

@ -2,6 +2,7 @@ export { default as Modal } from './Modal.svelte';
export { default as ConfirmationModal } from './ConfirmationModal.svelte';
export { default as FormModal } from './FormModal.svelte';
export { default as AppSlider } from './AppSlider.svelte';
export { default as BaseListView } from './BaseListView.svelte';
export type { AppItem } from './AppSlider.types';
// Network Graph