mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
d941ff2231
commit
c3cb9dd533
21 changed files with 878 additions and 1044 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">📌</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">📌</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>
|
||||
|
|
|
|||
|
|
@ -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">★</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">★</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>
|
||||
|
|
|
|||
|
|
@ -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] ?? '📄'}</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">📌</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] ?? '📄'}</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">📌</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</BaseListView>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">📌</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">📌</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>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
♫
|
||||
</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"
|
||||
>
|
||||
♫
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">★</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">★</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>
|
||||
|
|
|
|||
|
|
@ -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'] ?? '🌱'}</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">💧</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'] ?? '🌱'}
|
||||
</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">💧</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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ä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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
packages/shared-ui/src/organisms/BaseListView.svelte
Normal file
101
packages/shared-ui/src/organisms/BaseListView.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue