feat(manacore): migrate all remaining widgets to local-first IndexedDB

Migrate 9 more dashboard widgets from REST API polling to direct
IndexedDB reads: Chat, Zitare, Picture, Clock, Storage, Mukke,
Presi, Context, ManaDeck.

All 13 data widgets now use reactive liveQuery reads. Only Credits
and Transactions remain API-based (server-authoritative data).

Added cross-app stores for: chat, zitare, picture, clock, storage,
mukke, presi, context, manadeck — with typed collection accessors
and reactive query hooks for each.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 10:45:30 +02:00
parent de6af126d6
commit aee0934caf
12 changed files with 785 additions and 962 deletions

View file

@ -1,66 +1,24 @@
<script lang="ts">
/**
* ChatRecentWidget - Recent AI conversations
* ChatRecentWidget - Recent AI conversations (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { chatService, type Conversation } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
import { useRecentConversations } from '$lib/data/cross-app-queries';
import { APP_URLS } from '@manacore/shared-branding';
const conversations = useRecentConversations(5);
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const chatUrl = isDev ? APP_URLS.chat.dev : APP_URLS.chat.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<Conversation[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const MAX_DISPLAY = 5;
async function load() {
state = 'loading';
retrying = true;
const result = await chatService.getRecentConversations(MAX_DISPLAY);
if (result.data) {
data = result.data;
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function formatDate(dateStr: string): string {
function formatTime(dateStr?: string): string {
if (!dateStr) return '';
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
if (date.toDateString() === yesterday.toDateString()) {
return 'Gestern';
}
return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
</script>
@ -73,49 +31,32 @@
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
{#if conversations.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if (conversations.value ?? []).length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">💭</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.chat.empty')}
</p>
<a
href={chatUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Chat starten
</a>
<div class="mb-2 text-3xl">💬</div>
<p class="text-sm text-muted-foreground">{$_('dashboard.widgets.chat.empty')}</p>
</div>
{:else}
<div class="space-y-2">
{#each data as conversation}
<div class="space-y-1">
{#each conversations.value ?? [] as conv (conv.id)}
<a
href="{chatUrl}/chat/{conversation.id}"
href="{chatUrl}/chat/{conv.id}"
target="_blank"
rel="noopener"
class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover"
class="flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
{#if conversation.isPinned}
📌
{:else}
🤖
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{conversation.title || 'Neue Unterhaltung'}
</p>
<p class="text-xs text-muted-foreground">
{formatDate(conversation.updatedAt)}
</p>
<p class="truncate text-sm font-medium">{conv.title || 'Neue Unterhaltung'}</p>
</div>
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatTime(conv.updatedAt)}
</span>
</a>
{/each}
</div>

View file

@ -1,190 +1,99 @@
<script lang="ts">
/**
* ClockTimersWidget - Active timers and alarms
* ClockTimersWidget - Active timers and alarms (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { clockService, type Timer, type Alarm, type ClockStats } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
import { useEnabledAlarms, useActiveTimers } from '$lib/data/cross-app-queries';
import { APP_URLS } from '@manacore/shared-branding';
const alarms = useEnabledAlarms();
const timers = useActiveTimers();
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const clockUrl = isDev ? APP_URLS.clock.dev : APP_URLS.clock.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let timers = $state<Timer[]>([]);
let alarms = $state<Alarm[]>([]);
let stats = $state<ClockStats | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
async function load() {
state = 'loading';
retrying = true;
const [timersResult, alarmsResult, statsResult] = await Promise.all([
clockService.getActiveTimers(),
clockService.getEnabledAlarms(),
clockService.getStats(),
]);
if (timersResult.data && alarmsResult.data && statsResult.data) {
timers = timersResult.data;
alarms = alarmsResult.data.slice(0, 3);
stats = statsResult.data;
state = 'success';
retryCount = 0;
} else {
error = timersResult.error || alarmsResult.error || statsResult.error;
state = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function formatTime(seconds: number): string {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatAlarmDays(days: number[]): string {
function formatRepeatDays(days?: number[]): string {
if (!days || days.length === 0) return 'Einmalig';
if (days.length === 7) return 'Täglich';
if (days.length === 5 && !days.includes(0) && !days.includes(6)) return 'Werktags';
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Wochenende';
return days.map((d) => DAY_NAMES[d]).join(', ');
}
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
return days.map((d) => dayNames[d]).join(', ');
function formatRemaining(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span></span>
{$_('dashboard.widgets.clock.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if timers.length === 0 && alarms.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">🕐</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.clock.empty')}
</p>
<a
href={clockUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.clock.open')}
</a>
{#if alarms.loading && timers.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else}
<!-- Stats row (if pomodoros today) -->
{#if stats && stats.pomodorosToday > 0}
<div class="mb-3 flex items-center justify-center gap-4 rounded-lg bg-surface-hover p-2">
<div class="flex items-center gap-1">
<span>🍅</span>
<span class="font-medium">{stats.pomodorosToday}</span>
<span class="text-xs text-muted-foreground">Pomodoros</span>
</div>
<div class="flex items-center gap-1">
<span>⏱️</span>
<span class="font-medium">{stats.focusTimeToday}</span>
<span class="text-xs text-muted-foreground">min</span>
</div>
</div>
{/if}
<!-- Active timers -->
{#if timers.length > 0}
<div class="mb-3">
<div class="mb-2 text-xs font-medium uppercase text-muted-foreground">
{$_('dashboard.widgets.clock.active_timers')}
</div>
<div class="space-y-2">
{#each timers as timer}
<div
class="flex items-center justify-between rounded-lg p-2 {timer.isRunning
? 'bg-primary/10'
: 'bg-surface-hover'}"
>
<div class="flex items-center gap-2">
{#if timer.isRunning}
<span class="relative flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-primary"></span>
</span>
{:else}
<span class="h-2 w-2 rounded-full bg-yellow-500"></span>
{/if}
<span class="text-sm font-medium">{timer.name || 'Timer'}</span>
</div>
<span class="font-mono text-sm font-bold {timer.isRunning ? 'text-primary' : ''}">
{formatTime(timer.remaining)}
</span>
</div>
{/each}
</div>
<!-- Active Timers -->
{#if (timers.value ?? []).length > 0}
<div class="mb-3 space-y-1">
{#each timers.value ?? [] as timer (timer.id)}
<div class="flex items-center justify-between rounded-lg px-2 py-1.5 bg-surface-hover">
<span class="text-sm font-medium">{timer.label || 'Timer'}</span>
<span class="text-sm font-mono text-primary">
{formatRemaining(timer.remainingSeconds)}
</span>
</div>
{/each}
</div>
{/if}
<!-- Alarms -->
{#if alarms.length > 0}
<div>
<div class="mb-2 text-xs font-medium uppercase text-muted-foreground">
{$_('dashboard.widgets.clock.alarms')}
</div>
<div class="space-y-2">
{#each alarms as alarm}
<div class="flex items-center justify-between rounded-lg bg-surface-hover p-2">
<div>
<div class="font-mono text-lg font-bold">{alarm.time}</div>
<div class="text-xs text-muted-foreground">
{alarm.name || formatAlarmDays(alarm.days)}
</div>
</div>
<div class="text-lg">🔔</div>
{#if (alarms.value ?? []).length > 0}
<div class="space-y-1">
{#each (alarms.value ?? []).slice(0, 3) as alarm (alarm.id)}
<div class="flex items-center justify-between rounded-lg px-2 py-1.5">
<div>
<span class="text-sm font-medium">{alarm.time}</span>
{#if alarm.label}
<span class="ml-2 text-xs text-muted-foreground">{alarm.label}</span>
{/if}
</div>
{/each}
</div>
<span class="text-xs text-muted-foreground">
{formatRepeatDays(alarm.repeatDays)}
</span>
</div>
{/each}
</div>
{/if}
<div class="mt-3 text-center">
<a
href={clockUrl}
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.clock.open')}
</a>
</div>
{#if (alarms.value ?? []).length === 0 && (timers.value ?? []).length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl"></div>
<p class="text-sm text-muted-foreground">{$_('dashboard.widgets.clock.empty')}</p>
</div>
{/if}
<a
href={clockUrl}
target="_blank"
rel="noopener"
class="mt-2 block text-center text-sm text-primary hover:underline"
>
Uhr öffnen →
</a>
{/if}
</div>

View file

@ -1,162 +1,75 @@
<script lang="ts">
/**
* ContextDocsWidget - Recent documents and spaces
* ContextDocsWidget - Recent documents and spaces (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { contextService, type ContextDocument, type ContextSpace } from '$lib/api/services';
import { useRecentDocuments, useSpaces } from '$lib/data/cross-app-queries';
import { APP_URLS } from '@manacore/shared-branding';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
const docs = useRecentDocuments(5);
const spaces = useSpaces();
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const contextUrl = isDev ? APP_URLS.context.dev : APP_URLS.context.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let documents = $state<ContextDocument[]>([]);
let spaces = $state<ContextSpace[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
function getSpaceName(spaceId: string): string {
const space = (spaces.value ?? []).find((s) => s.id === spaceId);
return space?.name ?? '';
}
const MAX_DISPLAY = 5;
function formatDate(dateStr?: string): string {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
const typeIcons: Record<string, string> = {
text: '📝',
context: '🧠',
prompt: '💬',
context: '📋',
prompt: '💡',
};
async function load() {
state = 'loading';
retrying = true;
const [docsResult, spacesResult] = await Promise.all([
contextService.getRecentDocuments(MAX_DISPLAY),
contextService.getSpaces(),
]);
if (docsResult.data || spacesResult.data) {
documents = docsResult.data || [];
spaces = spacesResult.data || [];
state = 'success';
retryCount = 0;
} else {
error = docsResult.error || spacesResult.error;
state = 'error';
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function getSpaceName(spaceId: string): string | null {
return spaces.find((s) => s.id === spaceId)?.name || null;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
});
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🧠</span>
<span>📝</span>
{$_('dashboard.widgets.context.title')}
</h3>
{#if spaces.length > 0}
<span class="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{spaces.length} Spaces
</span>
{/if}
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if documents.length === 0 && spaces.length === 0}
{#if docs.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if (docs.value ?? []).length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">📚</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.context.empty')}
</p>
<a
href={contextUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Space erstellen
</a>
<div class="mb-2 text-3xl">📝</div>
<p class="text-sm text-muted-foreground">{$_('dashboard.widgets.context.empty')}</p>
</div>
{:else}
<!-- Recent documents -->
{#if documents.length > 0}
<div class="space-y-1">
{#each documents as doc}
<a
href="{contextUrl}/doc/{doc.shortId}"
target="_blank"
rel="noopener"
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span class="text-base">{typeIcons[doc.type] || '📄'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{doc.title}</p>
{#if getSpaceName(doc.spaceId)}
<p class="truncate text-xs text-muted-foreground">{getSpaceName(doc.spaceId)}</p>
{/if}
</div>
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatDate(doc.updatedAt)}
</span>
</a>
{/each}
</div>
{:else}
<!-- No documents but has spaces -->
<div class="mb-3 space-y-1">
{#each spaces.slice(0, 3) as space}
<a
href="{contextUrl}/space/{space.id}"
target="_blank"
rel="noopener"
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span class="text-base">{space.pinned ? '📌' : '📁'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{space.name}</p>
{#if space.description}
<p class="truncate text-xs text-muted-foreground">{space.description}</p>
{/if}
</div>
</a>
{/each}
</div>
{/if}
<div class="mt-3 text-center">
<a
href={contextUrl}
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
Alle Dokumente
</a>
<div class="space-y-1">
{#each docs.value ?? [] as doc (doc.id)}
<a
href="{contextUrl}/spaces/{doc.spaceId}/documents/{doc.id}"
target="_blank"
rel="noopener"
class="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span>{typeIcons[doc.type ?? 'text'] ?? '📄'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{doc.title}</p>
{#if getSpaceName(doc.spaceId)}
<p class="truncate text-xs text-muted-foreground">{getSpaceName(doc.spaceId)}</p>
{/if}
</div>
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatDate(doc.updatedAt)}
</span>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -1,161 +1,61 @@
<script lang="ts">
/**
* ManadeckProgressWidget - Learning progress and due cards
* ManadeckProgressWidget - Learning progress (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { manadeckService, type LearningProgress, type Deck } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
import { useManadeckProgress } from '$lib/data/cross-app-queries';
import { APP_URLS } from '@manacore/shared-branding';
const progress = useManadeckProgress();
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const manadeckUrl = isDev ? APP_URLS.manadeck.dev : APP_URLS.manadeck.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let progress = $state<LearningProgress | null>(null);
let decks = $state<Deck[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
async function load() {
state = 'loading';
retrying = true;
const [progressResult, decksResult] = await Promise.all([
manadeckService.getLearningProgress(),
manadeckService.getDecks(),
]);
if (progressResult.data && decksResult.data) {
progress = progressResult.data;
decks = decksResult.data;
state = 'success';
retryCount = 0;
} else {
error = progressResult.error || decksResult.error;
state = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
// Calculate progress percentage
const progressPercent = $derived(
progress && progress.totalCards > 0
? Math.round((progress.cardsLearned / progress.totalCards) * 100)
: 0
);
// Get decks with due cards
const decksWithDue = $derived(decks.filter((d) => d.dueCount > 0).slice(0, 3));
// Total due cards
const totalDue = $derived(decks.reduce((sum, d) => sum + d.dueCount, 0));
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🎴</span>
{$_('dashboard.widgets.manadeck.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if !progress || decks.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">📚</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.manadeck.empty')}
</p>
{#if progress.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else}
<div class="mb-3 grid grid-cols-2 gap-3">
<div class="rounded-lg bg-surface-hover p-2 text-center">
<div class="text-lg font-bold">{progress.value.totalCards}</div>
<div class="text-xs text-muted-foreground">Karten</div>
</div>
<div class="rounded-lg bg-surface-hover p-2 text-center">
<div class="text-lg font-bold">{progress.value.cardsLearned}</div>
<div class="text-xs text-muted-foreground">Gelernt</div>
</div>
<div class="rounded-lg bg-surface-hover p-2 text-center">
<div class="text-lg font-bold">{progress.value.totalDecks}</div>
<div class="text-xs text-muted-foreground">Decks</div>
</div>
<div class="rounded-lg bg-surface-hover p-2 text-center">
<div class="text-lg font-bold text-primary">{progress.value.dueForReview}</div>
<div class="text-xs text-muted-foreground">Fällig</div>
</div>
</div>
{#if progress.value.dueForReview > 0}
<a
href={manadeckUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
class="block rounded-lg bg-primary/10 py-2 text-center text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.manadeck.create_deck')}
{progress.value.dueForReview} Karten wiederholen →
</a>
</div>
{:else}
<!-- Stats row -->
<div class="mb-4 grid grid-cols-3 gap-2 text-center">
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-primary">{progress.streakDays}</div>
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.streak')}</div>
</div>
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-orange-500">{totalDue}</div>
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.due')}</div>
</div>
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-green-500">{progress.reviewsToday}</div>
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.today')}</div>
</div>
</div>
<!-- Progress bar -->
<div class="mb-4">
<div class="mb-1 flex justify-between text-xs text-muted-foreground">
<span>{$_('dashboard.widgets.manadeck.learned')}</span>
<span>{progressPercent}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-surface-hover">
<div
class="h-full rounded-full bg-primary transition-all duration-500"
style="width: {progressPercent}%"
></div>
</div>
</div>
<!-- Decks with due cards -->
{#if decksWithDue.length > 0}
<div class="space-y-2">
{#each decksWithDue as deck}
<a
href="{manadeckUrl}/deck/{deck.id}/study"
target="_blank"
rel="noopener"
class="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<span class="truncate text-sm font-medium">{deck.name}</span>
<span class="flex items-center gap-1 text-sm text-orange-500">
{deck.dueCount}
<span class="text-xs text-muted-foreground"
>{$_('dashboard.widgets.manadeck.due')}</span
>
</span>
</a>
{/each}
</div>
{/if}
{#if totalDue > 0}
<div class="mt-3 text-center">
<a
href="{manadeckUrl}/study"
target="_blank"
rel="noopener"
class="inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$_('dashboard.widgets.manadeck.start_study')}
</a>
</div>
{/if}
{/if}
</div>

View file

@ -1,107 +1,55 @@
<script lang="ts">
/**
* MukkeLibraryWidget - Music library stats and recent songs
* MukkeLibraryWidget - Music library stats (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { mukkeService, type MukkeStats, type Song } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
import { useMukkeStats } from '$lib/data/cross-app-queries';
const stats = useMukkeStats();
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const mukkeUrl = isDev ? 'http://localhost:5180' : 'https://mukke.mana.how';
let state = $state<'loading' | 'success' | 'error'>('loading');
let stats = $state<MukkeStats | null>(null);
let recentSongs = $state<Song[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
async function load() {
state = 'loading';
retrying = true;
const [statsResult, songsResult] = await Promise.all([
mukkeService.getStats(),
mukkeService.getRecentSongs(5),
]);
if (statsResult.data) {
stats = statsResult.data;
recentSongs = songsResult.data || [];
state = 'success';
retryCount = 0;
} else {
error = statsResult.error || songsResult.error;
state = 'error';
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
function formatDuration(seconds?: number): string {
if (!seconds) return '';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
onMount(load);
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🎵</span>
{$_('dashboard.widgets.mukke.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if !stats || stats.totalSongs === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">🎶</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.mukke.empty')}
</p>
<a
href={mukkeUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Musik entdecken
</a>
{#if stats.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else}
<!-- Stats row -->
<div class="mb-4 grid grid-cols-3 gap-2 text-center">
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-primary">{stats.totalSongs}</div>
<div class="text-xs text-muted-foreground">Songs</div>
<div class="mb-3 flex gap-4 text-sm">
<div>
<span class="font-semibold">{stats.value.totalSongs}</span>
<span class="text-muted-foreground"> Songs</span>
</div>
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-orange-500">{stats.totalPlaylists}</div>
<div class="text-xs text-muted-foreground">Playlists</div>
<div>
<span class="font-semibold">{stats.value.totalPlaylists}</span>
<span class="text-muted-foreground"> Playlists</span>
</div>
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-green-500">{stats.favoriteCount}</div>
<div class="text-xs text-muted-foreground">Favoriten</div>
<div>
<span class="font-semibold">{stats.value.favoriteCount}</span>
<span class="text-muted-foreground"></span>
</div>
</div>
<!-- Recent songs -->
{#if recentSongs.length > 0}
{#if stats.value.recentSongs.length > 0}
<div class="space-y-1">
{#each recentSongs as song}
<div
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span class="text-base">{song.favorite ? '❤️' : '🎵'}</span>
{#each stats.value.recentSongs as song (song.id)}
<div class="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-hover">
<span class="text-sm">🎵</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{song.title}</p>
{#if song.artist}
@ -110,7 +58,7 @@
</div>
{#if song.duration}
<span class="flex-shrink-0 text-xs text-muted-foreground">
{mukkeService.formatDuration(song.duration)}
{formatDuration(song.duration)}
</span>
{/if}
</div>
@ -118,15 +66,6 @@
</div>
{/if}
<div class="mt-3 text-center">
<a
href={mukkeUrl}
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
Bibliothek öffnen
</a>
</div>
<p class="mt-2 text-center text-xs text-muted-foreground">Mukke</p>
{/if}
</div>

View file

@ -1,73 +1,16 @@
<script lang="ts">
/**
* PictureRecentWidget - Recent AI-generated images
* PictureRecentWidget - Recent AI-generated images (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { pictureService, type GeneratedImage } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
import { useRecentImages } from '$lib/data/cross-app-queries';
import { APP_URLS } from '@manacore/shared-branding';
const images = useRecentImages(6);
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const pictureUrl = isDev ? APP_URLS.picture.dev : APP_URLS.picture.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<GeneratedImage[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const MAX_DISPLAY = 6;
async function load() {
state = 'loading';
retrying = true;
const result = await pictureService.getRecentGenerations(MAX_DISPLAY);
if (result.data) {
data = result.data;
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
if (date.toDateString() === yesterday.toDateString()) {
return 'Gestern';
}
return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
function truncatePrompt(prompt: string, maxLength = 40): string {
if (prompt.length <= maxLength) return prompt;
return prompt.slice(0, maxLength) + '...';
}
</script>
<div>
@ -78,59 +21,37 @@
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
{#if images.loading}
<div class="grid grid-cols-3 gap-2">
{#each Array(6) as _}
<div class="aspect-square animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if (images.value ?? []).length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">🖼️</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.picture.empty')}
</p>
<a
href={pictureUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.picture.create')}
</a>
<div class="mb-2 text-3xl">🎨</div>
<p class="text-sm text-muted-foreground">{$_('dashboard.widgets.picture.empty')}</p>
</div>
{:else}
<div class="grid grid-cols-3 gap-2">
{#each data as image}
{#each images.value ?? [] as image (image.id)}
<a
href="{pictureUrl}/gallery/{image.id}"
href="{pictureUrl}/images/{image.id}"
target="_blank"
rel="noopener"
class="group relative aspect-square overflow-hidden rounded-lg bg-surface-hover"
>
<img
src={image.thumbnailUrl || image.imageUrl}
alt={truncatePrompt(image.prompt)}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
<div
class="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100"
>
<p class="p-2 text-xs text-white">{truncatePrompt(image.prompt, 30)}</p>
</div>
{#if image.isFavorite}
<div class="absolute right-1 top-1 text-sm">❤️</div>
{#if image.publicUrl}
<img
src={image.publicUrl}
alt={image.prompt || 'Generated image'}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full w-full items-center justify-center text-2xl">🖼️</div>
{/if}
</a>
{/each}
</div>
<div class="mt-3 text-center">
<a
href="{pictureUrl}/gallery"
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.picture.view_all')}
</a>
</div>
{/if}
</div>

View file

@ -1,107 +1,53 @@
<script lang="ts">
/**
* PresiDecksWidget - Recent presentations
* PresiDecksWidget - Recent presentations (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { presiService, type PresiDeck } from '$lib/api/services';
import { useRecentDecks } from '$lib/data/cross-app-queries';
import { APP_URLS } from '@manacore/shared-branding';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
const decks = useRecentDecks(5);
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const presiUrl = isDev ? APP_URLS.presi.dev : APP_URLS.presi.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let decks = $state<PresiDeck[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const MAX_DISPLAY = 5;
async function load() {
state = 'loading';
retrying = true;
const result = await presiService.getRecentDecks(MAX_DISPLAY);
if (result.data) {
decks = result.data;
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
});
function formatDate(dateStr?: string): string {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>📊</span>
{$_('dashboard.widgets.presi.title')}
</h3>
{#if decks.length > 0}
<span class="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{decks.length}
</span>
{/if}
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if decks.length === 0}
{#if decks.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if (decks.value ?? []).length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">🎨</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.presi.empty')}
</p>
<a
href={presiUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Präsentation erstellen
</a>
<div class="mb-2 text-3xl">📊</div>
<p class="text-sm text-muted-foreground">{$_('dashboard.widgets.presi.empty')}</p>
</div>
{:else}
<div class="space-y-1">
{#each decks as deck}
{#each decks.value ?? [] as deck (deck.id)}
<a
href="{presiUrl}/deck/{deck.id}"
href="{presiUrl}/decks/{deck.id}"
target="_blank"
rel="noopener"
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
class="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span class="text-base">{deck.isPublic ? '🌐' : '📄'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{deck.title}</p>
{#if deck.description}
<p class="truncate text-xs text-muted-foreground">{deck.description}</p>
{/if}
</div>
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatDate(deck.updatedAt)}
@ -109,16 +55,5 @@
</a>
{/each}
</div>
<div class="mt-3 text-center">
<a
href={presiUrl}
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
Alle Präsentationen
</a>
</div>
{/if}
</div>

View file

@ -1,115 +1,83 @@
<script lang="ts">
/**
* StorageUsageWidget - Displays storage usage and recent files
* StorageUsageWidget - Storage stats and recent files (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { storageService, type StorageStats } from '$lib/api/services/storage';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
import { useStorageStats } from '$lib/data/cross-app-queries';
import { APP_URLS } from '@manacore/shared-branding';
const stats = useStorageStats();
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const storageUrl = isDev ? APP_URLS.storage.dev : APP_URLS.storage.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<StorageStats | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
async function load() {
state = 'loading';
retrying = true;
try {
const result = await storageService.getStats();
if (result.error) {
throw new Error(result.error);
}
data = result.data;
state = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load storage stats';
state = 'error';
} finally {
retrying = false;
}
}
onMount(load);
function formatSize(bytes: number): string {
return storageService.formatSize(bytes);
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
function getFileIcon(mimeType: string): string {
function getFileIcon(mimeType?: string): string {
if (!mimeType) return '📄';
if (mimeType.startsWith('image/')) return '🖼️';
if (mimeType.startsWith('video/')) return '🎬';
if (mimeType.startsWith('audio/')) return '🎵';
if (mimeType.includes('pdf')) return '📄';
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('tar'))
return '📦';
if (mimeType.includes('text') || mimeType.includes('document')) return '📝';
if (mimeType.includes('pdf')) return '📕';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
return '📁';
if (mimeType.includes('document') || mimeType.includes('word')) return '📝';
return '📄';
}
</script>
<div>
<h3 class="mb-3 flex items-center gap-2 text-lg font-semibold">
<span>💾</span>
{$_('dashboard.widgets.storage.title')}
</h3>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>💾</span>
{$_('dashboard.widgets.storage.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data}
<div class="space-y-4">
<!-- Storage Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg bg-muted/50 p-3">
<p class="text-muted-foreground text-xs">{$_('dashboard.widgets.storage.total_size')}</p>
<p class="text-xl font-bold">{formatSize(data.totalSize)}</p>
</div>
<div class="rounded-lg bg-muted/50 p-3">
<p class="text-muted-foreground text-xs">{$_('dashboard.widgets.storage.files')}</p>
<p class="text-xl font-bold">{data.totalFiles}</p>
</div>
</div>
<!-- Recent Files -->
{#if data.recentFiles && data.recentFiles.length > 0}
<div>
<p class="text-muted-foreground mb-2 text-sm font-medium">
{$_('dashboard.widgets.storage.recent')}
</p>
<ul class="space-y-2">
{#each data.recentFiles.slice(0, 3) as file}
<li class="flex items-center gap-2 text-sm">
<span>{getFileIcon(file.mimeType)}</span>
<span class="flex-1 truncate">{file.name}</span>
<span class="text-muted-foreground text-xs">{formatSize(file.size)}</span>
</li>
{/each}
</ul>
</div>
{:else}
<p class="text-muted-foreground text-sm">{$_('dashboard.widgets.storage.empty')}</p>
{/if}
<a
href={storageUrl}
target="_blank"
rel="noopener"
class="mt-2 block w-full rounded-lg bg-primary/10 py-2 text-center text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.storage.open')}
</a>
{#if stats.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else}
<div class="mb-3 flex gap-4 text-sm">
<div>
<span class="font-semibold">{stats.value.totalFiles}</span>
<span class="text-muted-foreground"> Dateien</span>
</div>
<div>
<span class="font-semibold">{formatSize(stats.value.totalSize)}</span>
<span class="text-muted-foreground"> gesamt</span>
</div>
</div>
{#if stats.value.recentFiles.length > 0}
<div class="space-y-1">
{#each stats.value.recentFiles as file (file.id)}
<div class="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-hover">
<span>{getFileIcon(file.mimeType)}</span>
<span class="min-w-0 flex-1 truncate text-sm">{file.name}</span>
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatSize(file.size || 0)}
</span>
</div>
{/each}
</div>
{/if}
<a
href={storageUrl}
target="_blank"
rel="noopener"
class="mt-2 block text-center text-sm text-primary hover:underline"
>
Storage öffnen →
</a>
{/if}
</div>

View file

@ -1,117 +1,51 @@
<script lang="ts">
/**
* ZitareQuoteWidget - Random inspiring quote from favorites
* ZitareQuoteWidget - Random favorite quote (local-first)
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { zitareService, type Favorite } from '$lib/api/services';
import { useRandomFavorite } from '$lib/data/cross-app-queries';
import { APP_URLS } from '@manacore/shared-branding';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<Favorite | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const favorite = useRandomFavorite();
// Determine app URL based on environment
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const zitareUrl = isDev ? APP_URLS.zitare.dev : APP_URLS.zitare.prod;
async function load() {
state = 'loading';
retrying = true;
const result = await zitareService.getRandomFavorite();
if (result.data) {
data = result.data;
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
async function loadNewQuote() {
await load();
}
onMount(load);
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>=<3D></span>
<span>💡</span>
{$_('dashboard.widgets.zitare.title')}
</h3>
{#if state === 'success' && data}
<button
type="button"
onclick={loadNewQuote}
class="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
title={$_('dashboard.widgets.zitare.refresh')}
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
</button>
{/if}
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if !data}
{#if favorite.loading}
<div class="h-16 animate-pulse rounded bg-surface-hover"></div>
{:else if !favorite.value}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">(</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.zitare.empty')}
</p>
<div class="mb-2 text-3xl">💡</div>
<p class="text-sm text-muted-foreground">{$_('dashboard.widgets.zitare.empty')}</p>
<a
href={zitareUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.zitare.explore')}
Zitate entdecken
</a>
</div>
{:else}
<div class="space-y-3">
<!-- Quote display -->
<blockquote class="border-l-4 border-primary/30 pl-4">
<p class="text-sm italic text-muted-foreground">
"{data.quoteId}"
</p>
</blockquote>
<!-- Note: The Favorite only contains quoteId, we'd need a separate
API call to get the full quote text and author. For now, showing ID -->
<a
href={zitareUrl}
target="_blank"
rel="noopener"
class="block text-center text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.zitare.view_all')} <20>
</a>
</div>
<a
href={zitareUrl}
target="_blank"
rel="noopener"
class="block rounded-lg p-3 transition-colors hover:bg-surface-hover"
>
<p class="text-sm italic text-muted-foreground">
Favorit #{favorite.value.quoteId}
</p>
</a>
{/if}
</div>

View file

@ -11,9 +11,35 @@ import {
crossTaskCollection,
crossEventCollection,
crossContactCollection,
crossConversationCollection,
crossFavoriteCollection,
crossImageCollection,
crossAlarmCollection,
crossTimerCollection,
crossFileCollection,
crossSongCollection,
crossPlaylistCollection,
crossPresiDeckCollection,
crossSpaceCollection,
crossDocumentCollection,
crossManadeckDeckCollection,
crossManadeckCardCollection,
type CrossAppTask,
type CrossAppEvent,
type CrossAppContact,
type CrossAppConversation,
type CrossAppFavorite,
type CrossAppImage,
type CrossAppAlarm,
type CrossAppTimer,
type CrossAppFile,
type CrossAppSong,
type CrossAppPlaylist,
type CrossAppDeck,
type CrossAppSpace,
type CrossAppDocument,
type CrossAppManadeckDeck,
type CrossAppManadeckCard,
} from './cross-app-stores';
// ─── Todo Queries ───────────────────────────────────────────
@ -112,3 +138,170 @@ export function useFavoriteContacts(limit = 5) {
return all.filter((c) => c.isFavorite && !c.isArchived && !c.deletedAt).slice(0, limit);
}, [] as CrossAppContact[]);
}
// ─── Chat Queries ───────────────────────────────────────────
/** Recent conversations, sorted by updatedAt desc. */
export function useRecentConversations(limit = 5) {
return useLiveQueryWithDefault(async () => {
const all = await crossConversationCollection.getAll(undefined, {
sortBy: 'updatedAt',
sortDirection: 'desc',
});
return all.filter((c) => !c.isArchived && !c.deletedAt).slice(0, limit);
}, [] as CrossAppConversation[]);
}
// ─── Zitare Queries ─────────────────────────────────────────
/** A random favorite quote. */
export function useRandomFavorite() {
return useLiveQueryWithDefault(
async () => {
const all = await crossFavoriteCollection.getAll();
const active = all.filter((f) => !f.deletedAt);
if (active.length === 0) return null;
return active[Math.floor(Math.random() * active.length)];
},
null as CrossAppFavorite | null
);
}
// ─── Picture Queries ────────────────────────────────────────
/** Recent generated images. */
export function useRecentImages(limit = 6) {
return useLiveQueryWithDefault(async () => {
const all = await crossImageCollection.getAll(undefined, {
sortBy: 'createdAt',
sortDirection: 'desc',
});
return all.filter((i) => !i.archivedAt && !i.deletedAt).slice(0, limit);
}, [] as CrossAppImage[]);
}
// ─── Clock Queries ──────────────────────────────────────────
/** Enabled alarms. */
export function useEnabledAlarms() {
return useLiveQueryWithDefault(async () => {
const all = await crossAlarmCollection.getAll();
return all.filter((a) => a.enabled && !a.deletedAt);
}, [] as CrossAppAlarm[]);
}
/** Active/running timers. */
export function useActiveTimers() {
return useLiveQueryWithDefault(async () => {
const all = await crossTimerCollection.getAll();
return all.filter((t) => (t.status === 'running' || t.status === 'paused') && !t.deletedAt);
}, [] as CrossAppTimer[]);
}
// ─── Storage Queries ────────────────────────────────────────
/** Storage stats: total files and total size. */
export function useStorageStats() {
return useLiveQueryWithDefault(
async () => {
const files = await crossFileCollection.getAll();
const active = files.filter((f) => !f.isDeleted && !f.deletedAt);
const totalSize = active.reduce((sum, f) => sum + (f.size || 0), 0);
const recent = active
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 5);
return { totalFiles: active.length, totalSize, recentFiles: recent };
},
{ totalFiles: 0, totalSize: 0, recentFiles: [] as CrossAppFile[] }
);
}
// ─── Mukke Queries ──────────────────────────────────────────
/** Mukke library stats + recent songs. */
export function useMukkeStats() {
return useLiveQueryWithDefault(
async () => {
const songs = await crossSongCollection.getAll();
const playlists = await crossPlaylistCollection.getAll();
const activeSongs = songs.filter((s) => !s.deletedAt);
const activePlaylists = playlists.filter((p) => !p.deletedAt);
const recent = activeSongs
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 5);
return {
totalSongs: activeSongs.length,
totalPlaylists: activePlaylists.length,
favoriteCount: activeSongs.filter((s) => s.favorite).length,
recentSongs: recent,
};
},
{ totalSongs: 0, totalPlaylists: 0, favoriteCount: 0, recentSongs: [] as CrossAppSong[] }
);
}
// ─── Presi Queries ──────────────────────────────────────────
/** Recent presentation decks. */
export function useRecentDecks(limit = 5) {
return useLiveQueryWithDefault(async () => {
const all = await crossPresiDeckCollection.getAll(undefined, {
sortBy: 'updatedAt',
sortDirection: 'desc',
});
return all.filter((d) => !d.deletedAt).slice(0, limit);
}, [] as CrossAppDeck[]);
}
// ─── Context Queries ────────────────────────────────────────
/** Recent documents + spaces. */
export function useRecentDocuments(limit = 5) {
return useLiveQueryWithDefault(async () => {
const all = await crossDocumentCollection.getAll(undefined, {
sortBy: 'updatedAt',
sortDirection: 'desc',
});
return all.filter((d) => !d.deletedAt).slice(0, limit);
}, [] as CrossAppDocument[]);
}
export function useSpaces() {
return useLiveQueryWithDefault(async () => {
const all = await crossSpaceCollection.getAll(undefined, {
sortBy: 'pinned',
sortDirection: 'desc',
});
return all.filter((s) => !s.deletedAt);
}, [] as CrossAppSpace[]);
}
// ─── ManaDeck Queries ───────────────────────────────────────
/** ManaDeck learning progress. */
export function useManadeckProgress() {
return useLiveQueryWithDefault(
async () => {
const decks = await crossManadeckDeckCollection.getAll();
const cards = await crossManadeckCardCollection.getAll();
const activeDecks = decks.filter((d) => !d.deletedAt);
const activeCards = cards.filter((c) => !c.deletedAt);
const now = new Date().toISOString();
const dueCards = activeCards.filter((c) => c.nextReview && c.nextReview <= now);
return {
totalDecks: activeDecks.length,
totalCards: activeCards.length,
cardsLearned: activeCards.filter((c) => (c.reviewCount ?? 0) > 0).length,
dueForReview: dueCards.length,
decks: activeDecks,
};
},
{
totalDecks: 0,
totalCards: 0,
cardsLearned: 0,
dueForReview: 0,
decks: [] as CrossAppManadeckDeck[],
}
);
}

View file

@ -76,6 +76,144 @@ export interface CrossAppContact extends BaseRecord {
isArchived?: boolean;
}
// ─── Chat Types ─────────────────────────────────────────────
export interface CrossAppConversation extends BaseRecord {
title?: string;
modelId?: string;
isArchived?: boolean;
isPinned?: boolean;
spaceId?: string;
}
export interface CrossAppMessage extends BaseRecord {
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
}
// ─── Zitare Types ───────────────────────────────────────────
export interface CrossAppFavorite extends BaseRecord {
quoteId: string;
}
// ─── Picture Types ──────────────────────────────────────────
export interface CrossAppImage extends BaseRecord {
prompt?: string;
publicUrl?: string;
storagePath?: string;
filename?: string;
width?: number;
height?: number;
isFavorite?: boolean;
isPublic?: boolean;
archivedAt?: string | null;
}
// ─── Clock Types ────────────────────────────────────────────
export interface CrossAppAlarm extends BaseRecord {
label?: string;
time: string;
enabled: boolean;
repeatDays?: number[];
}
export interface CrossAppTimer extends BaseRecord {
label?: string;
durationSeconds: number;
remainingSeconds: number;
status: 'idle' | 'running' | 'paused' | 'finished';
startedAt?: string;
}
// ─── Storage Types ──────────────────────────────────────────
export interface CrossAppFile extends BaseRecord {
name: string;
originalName?: string;
mimeType?: string;
size?: number;
parentFolderId?: string | null;
isFavorite?: boolean;
isDeleted?: boolean;
}
export interface CrossAppFolder extends BaseRecord {
name: string;
parentFolderId?: string | null;
path?: string;
depth?: number;
isFavorite?: boolean;
isDeleted?: boolean;
}
// ─── Mukke Types ────────────────────────────────────────────
export interface CrossAppSong extends BaseRecord {
title: string;
artist?: string;
album?: string;
duration?: number;
favorite?: boolean;
}
export interface CrossAppPlaylist extends BaseRecord {
name: string;
description?: string;
}
// ─── Presi Types ────────────────────────────────────────────
export interface CrossAppDeck extends BaseRecord {
title: string;
description?: string;
isPublic?: boolean;
}
export interface CrossAppSlide extends BaseRecord {
deckId: string;
order: number;
content?: unknown;
}
// ─── Context Types ──────────────────────────────────────────
export interface CrossAppSpace extends BaseRecord {
name: string;
description?: string;
pinned?: boolean;
}
export interface CrossAppDocument extends BaseRecord {
spaceId: string;
title: string;
type?: 'text' | 'context' | 'prompt';
pinned?: boolean;
}
// ─── ManaDeck Types ─────────────────────────────────────────
export interface CrossAppManadeckDeck extends BaseRecord {
name: string;
description?: string;
color?: string;
cardCount?: number;
lastStudied?: string;
isPublic?: boolean;
}
export interface CrossAppManadeckCard extends BaseRecord {
deckId: string;
front: string;
back: string;
difficulty?: number;
nextReview?: string;
reviewCount?: number;
}
// ─── Store Instances ────────────────────────────────────────
// These open existing IndexedDB databases created by other apps.
// No sync config — ManaCore only reads, the owning app handles sync.
@ -126,9 +264,119 @@ export const contactsReader = createLocalStore({
],
});
// Typed collection accessors
export const chatReader = createLocalStore({
appId: 'chat',
collections: [
{ name: 'conversations', indexes: ['isArchived', 'isPinned', 'spaceId'] },
{ name: 'messages', indexes: ['conversationId', 'sender', '[conversationId+sender]'] },
],
});
export const zitareReader = createLocalStore({
appId: 'zitare',
collections: [{ name: 'favorites', indexes: ['quoteId'] }],
});
export const pictureReader = createLocalStore({
appId: 'picture',
collections: [{ name: 'images', indexes: ['isFavorite', 'isPublic', 'archivedAt', 'prompt'] }],
});
export const clockReader = createLocalStore({
appId: 'clock',
collections: [
{ name: 'alarms', indexes: ['enabled', 'time'] },
{ name: 'timers', indexes: ['status'] },
],
});
export const storageReader = createLocalStore({
appId: 'storage',
collections: [
{
name: 'files',
indexes: ['parentFolderId', 'mimeType', 'isFavorite', 'isDeleted', 'name'],
},
{ name: 'folders', indexes: ['parentFolderId', 'path', 'depth', 'isFavorite', 'isDeleted'] },
],
});
export const mukkeReader = createLocalStore({
appId: 'mukke',
collections: [
{ name: 'songs', indexes: ['artist', 'album', 'genre', 'favorite', 'title'] },
{ name: 'playlists', indexes: ['name'] },
],
});
export const presiReader = createLocalStore({
appId: 'presi',
collections: [
{ name: 'decks', indexes: ['isPublic'] },
{ name: 'slides', indexes: ['deckId', 'order', '[deckId+order]'] },
],
});
export const contextReader = createLocalStore({
appId: 'context',
collections: [
{ name: 'spaces', indexes: ['pinned', 'prefix'] },
{ name: 'documents', indexes: ['spaceId', 'type', 'pinned', 'title', '[spaceId+type]'] },
],
});
export const manadeckReader = createLocalStore({
appId: 'manadeck',
collections: [
{ name: 'decks', indexes: ['isPublic'] },
{ name: 'cards', indexes: ['deckId', 'difficulty', 'nextReview', 'order', '[deckId+order]'] },
],
});
// ─── Typed Collection Accessors ─────────────────────────────
// Todo
export const crossTaskCollection = todoReader.collection<CrossAppTask>('tasks');
export const crossProjectCollection = todoReader.collection<CrossAppProject>('projects');
// Calendar
export const crossEventCollection = calendarReader.collection<CrossAppEvent>('events');
export const crossCalendarCollection = calendarReader.collection<CrossAppCalendar>('calendars');
// Contacts
export const crossContactCollection = contactsReader.collection<CrossAppContact>('contacts');
// Chat
export const crossConversationCollection =
chatReader.collection<CrossAppConversation>('conversations');
export const crossMessageCollection = chatReader.collection<CrossAppMessage>('messages');
// Zitare
export const crossFavoriteCollection = zitareReader.collection<CrossAppFavorite>('favorites');
// Picture
export const crossImageCollection = pictureReader.collection<CrossAppImage>('images');
// Clock
export const crossAlarmCollection = clockReader.collection<CrossAppAlarm>('alarms');
export const crossTimerCollection = clockReader.collection<CrossAppTimer>('timers');
// Storage
export const crossFileCollection = storageReader.collection<CrossAppFile>('files');
export const crossFolderCollection = storageReader.collection<CrossAppFolder>('folders');
// Mukke
export const crossSongCollection = mukkeReader.collection<CrossAppSong>('songs');
export const crossPlaylistCollection = mukkeReader.collection<CrossAppPlaylist>('playlists');
// Presi
export const crossPresiDeckCollection = presiReader.collection<CrossAppDeck>('decks');
export const crossSlideCollection = presiReader.collection<CrossAppSlide>('slides');
// Context
export const crossSpaceCollection = contextReader.collection<CrossAppSpace>('spaces');
export const crossDocumentCollection = contextReader.collection<CrossAppDocument>('documents');
// ManaDeck
export const crossManadeckDeckCollection = manadeckReader.collection<CrossAppManadeckDeck>('decks');
export const crossManadeckCardCollection = manadeckReader.collection<CrossAppManadeckCard>('cards');

View file

@ -10,7 +10,20 @@
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { tagLocalStore, tagMutations, useAllTags } from '$lib/stores/tags.svelte';
import { manacoreStore } from '$lib/data/local-store';
import { todoReader, calendarReader, contactsReader } from '$lib/data/cross-app-stores';
import {
todoReader,
calendarReader,
contactsReader,
chatReader,
zitareReader,
pictureReader,
clockReader,
storageReader,
mukkeReader,
presiReader,
contextReader,
manadeckReader,
} from '$lib/data/cross-app-stores';
import { dashboardStore } from '$lib/stores/dashboard.svelte';
import {
THEME_DEFINITIONS,
@ -211,6 +224,15 @@
todoReader.initialize(),
calendarReader.initialize(),
contactsReader.initialize(),
chatReader.initialize(),
zitareReader.initialize(),
pictureReader.initialize(),
clockReader.initialize(),
storageReader.initialize(),
mukkeReader.initialize(),
presiReader.initialize(),
contextReader.initialize(),
manadeckReader.initialize(),
]);
// Start syncing to server