mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
de6af126d6
commit
aee0934caf
12 changed files with 785 additions and 962 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue