feat(manacore): add Picture, ManaDeck, and Clock dashboard widgets

- Add 3 new widget types: picture-recent, manadeck-progress, clock-timers
- Create API services for Picture, ManaDeck, and Clock apps
- Add PictureRecentWidget showing recent AI-generated images
- Add ManadeckProgressWidget showing learning progress and due cards
- Add ClockTimersWidget showing active timers and alarms
- Update WidgetContainer to include new widget components
- Add i18n translations (DE/EN) for all new widgets
- Extend WIDGET_REGISTRY with metadata for new widgets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-05 03:45:07 +01:00
parent acb5d74420
commit 5fd5423f8e
11 changed files with 961 additions and 2 deletions

View file

@ -0,0 +1,199 @@
/**
* Clock API Service
*
* Fetches timers and alarms from local storage for dashboard widgets.
* Note: Clock app stores data in localStorage, not a backend.
*/
import type { ApiResult } from '../base-client';
/**
* Timer entity from Clock app
*/
export interface Timer {
id: string;
name: string;
duration: number; // Total duration in seconds
remaining: number; // Remaining time in seconds
isRunning: boolean;
isPaused: boolean;
createdAt: string;
completedAt?: string;
}
/**
* Alarm entity from Clock app
*/
export interface Alarm {
id: string;
name: string;
time: string; // HH:MM format
days: number[]; // 0-6 (Sunday to Saturday)
isEnabled: boolean;
sound?: string;
createdAt: string;
}
/**
* Pomodoro session from Clock app
*/
export interface PomodoroSession {
id: string;
type: 'work' | 'shortBreak' | 'longBreak';
duration: number;
completedAt: string;
}
/**
* Clock statistics
*/
export interface ClockStats {
activeTimers: number;
enabledAlarms: number;
pomodorosToday: number;
focusTimeToday: number; // In minutes
}
// LocalStorage keys (matching Clock app's storage)
const STORAGE_KEYS = {
timers: 'clock-timers',
alarms: 'clock-alarms',
pomodoros: 'clock-pomodoros',
};
/**
* Clock service for dashboard widgets
*
* Since Clock stores data in localStorage, this service reads from there.
*/
export const clockService = {
/**
* Get all timers
*/
async getTimers(): Promise<ApiResult<Timer[]>> {
try {
if (typeof window === 'undefined') {
return { data: [], error: null };
}
const stored = localStorage.getItem(STORAGE_KEYS.timers);
const timers = stored ? JSON.parse(stored) : [];
return { data: timers, error: null };
} catch {
return { data: null, error: 'Failed to load timers' };
}
},
/**
* Get active timers (running or paused with time remaining)
*/
async getActiveTimers(): Promise<ApiResult<Timer[]>> {
const result = await this.getTimers();
if (result.error || !result.data) {
return result;
}
const activeTimers = result.data.filter((t) => t.isRunning || (t.isPaused && t.remaining > 0));
return { data: activeTimers, error: null };
},
/**
* Get all alarms
*/
async getAlarms(): Promise<ApiResult<Alarm[]>> {
try {
if (typeof window === 'undefined') {
return { data: [], error: null };
}
const stored = localStorage.getItem(STORAGE_KEYS.alarms);
const alarms = stored ? JSON.parse(stored) : [];
return { data: alarms, error: null };
} catch {
return { data: null, error: 'Failed to load alarms' };
}
},
/**
* Get enabled alarms sorted by next trigger time
*/
async getEnabledAlarms(): Promise<ApiResult<Alarm[]>> {
const result = await this.getAlarms();
if (result.error || !result.data) {
return result;
}
const now = new Date();
const currentDay = now.getDay();
const currentTime = now.getHours() * 60 + now.getMinutes();
const enabledAlarms = result.data
.filter((a) => a.isEnabled)
.sort((a, b) => {
// Parse alarm times
const [aHours, aMinutes] = a.time.split(':').map(Number);
const [bHours, bMinutes] = b.time.split(':').map(Number);
const aTime = aHours * 60 + aMinutes;
const bTime = bHours * 60 + bMinutes;
// Sort by time of day
return aTime - bTime;
});
return { data: enabledAlarms, error: null };
},
/**
* Get clock statistics
*/
async getStats(): Promise<ApiResult<ClockStats>> {
try {
const [timersResult, alarmsResult] = await Promise.all([
this.getActiveTimers(),
this.getEnabledAlarms(),
]);
// Get pomodoro sessions for today
const pomodorosStored = localStorage.getItem(STORAGE_KEYS.pomodoros);
const pomodoros: PomodoroSession[] = pomodorosStored ? JSON.parse(pomodorosStored) : [];
const today = new Date().toDateString();
const todayPomodoros = pomodoros.filter(
(p) => new Date(p.completedAt).toDateString() === today
);
const focusTimeToday = todayPomodoros
.filter((p) => p.type === 'work')
.reduce((sum, p) => sum + p.duration, 0);
return {
data: {
activeTimers: timersResult.data?.length || 0,
enabledAlarms: alarmsResult.data?.length || 0,
pomodorosToday: todayPomodoros.filter((p) => p.type === 'work').length,
focusTimeToday: Math.round(focusTimeToday / 60),
},
error: null,
};
} catch {
return { data: null, error: 'Failed to load clock stats' };
}
},
/**
* Get next alarm time as a formatted string
*/
async getNextAlarmTime(): Promise<ApiResult<string | null>> {
const result = await this.getEnabledAlarms();
if (result.error || !result.data || result.data.length === 0) {
return { data: null, error: result.error };
}
// Get next alarm
const nextAlarm = result.data[0];
return { data: nextAlarm.time, error: null };
},
};

View file

@ -9,3 +9,6 @@ export { calendarService, type Calendar, type CalendarEvent } from './calendar';
export { chatService, type Conversation, type Message, type AiModel } from './chat';
export { contactsService, type Contact, type ContactActivity } from './contacts';
export { zitareService, type Favorite, type Quote, type QuoteList } from './zitare';
export { pictureService, type GeneratedImage, type GenerationStats } from './picture';
export { manadeckService, type Deck, type Card, type LearningProgress } from './manadeck';
export { clockService, type Timer, type Alarm, type ClockStats } from './clock';

View file

@ -0,0 +1,110 @@
/**
* ManaDeck API Service
*
* Fetches learning progress and deck data from the ManaDeck backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const MANADECK_API_URL = import.meta.env.PUBLIC_MANADECK_API_URL || 'http://localhost:3009';
const client = createApiClient(MANADECK_API_URL);
/**
* Deck entity from ManaDeck backend
*/
export interface Deck {
id: string;
userId: string;
name: string;
description?: string;
cardCount: number;
dueCount: number;
newCount: number;
createdAt: string;
updatedAt: string;
lastStudied?: string;
}
/**
* Card entity from ManaDeck backend
*/
export interface Card {
id: string;
deckId: string;
front: string;
back: string;
nextReview: string;
interval: number;
easeFactor: number;
repetitions: number;
createdAt: string;
updatedAt: string;
}
/**
* Learning progress statistics
*/
export interface LearningProgress {
totalCards: number;
cardsLearned: number;
cardsDueToday: number;
newCardsToday: number;
streakDays: number;
reviewsToday: number;
averageRetention: number;
}
/**
* ManaDeck service for dashboard widgets
*/
export const manadeckService = {
/**
* Get user's decks
*/
async getDecks(): Promise<ApiResult<Deck[]>> {
return client.get<Deck[]>('/api/decks');
},
/**
* Get learning progress
*/
async getLearningProgress(): Promise<ApiResult<LearningProgress>> {
return client.get<LearningProgress>('/api/progress');
},
/**
* Get cards due for review today
*/
async getDueCards(limit = 10): Promise<ApiResult<Card[]>> {
return client.get<Card[]>(`/api/cards/due?limit=${limit}`);
},
/**
* Get total due cards count across all decks
*/
async getTotalDueCount(): Promise<ApiResult<number>> {
const result = await this.getDecks();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
const totalDue = result.data.reduce((sum, deck) => sum + deck.dueCount, 0);
return { data: totalDue, error: null };
},
/**
* Get study streak
*/
async getStreak(): Promise<ApiResult<number>> {
const result = await this.getLearningProgress();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.streakDays, error: null };
},
};

View file

@ -0,0 +1,81 @@
/**
* Picture API Service
*
* Fetches recent AI-generated images from the Picture backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const PICTURE_API_URL = import.meta.env.PUBLIC_PICTURE_API_URL || 'http://localhost:3006';
const client = createApiClient(PICTURE_API_URL);
/**
* Generated image entity from Picture backend
*/
export interface GeneratedImage {
id: string;
userId: string;
prompt: string;
negativePrompt?: string;
imageUrl: string;
thumbnailUrl?: string;
width: number;
height: number;
model: string;
seed?: number;
steps?: number;
cfgScale?: number;
createdAt: string;
isFavorite?: boolean;
tags?: string[];
}
/**
* Generation statistics
*/
export interface GenerationStats {
totalGenerations: number;
thisMonth: number;
favoriteCount: number;
}
/**
* Picture service for dashboard widgets
*/
export const pictureService = {
/**
* Get user's recent generations
*/
async getRecentGenerations(limit = 6): Promise<ApiResult<GeneratedImage[]>> {
return client.get<GeneratedImage[]>(`/api/generations?limit=${limit}&sort=createdAt:desc`);
},
/**
* Get user's favorite images
*/
async getFavorites(limit = 6): Promise<ApiResult<GeneratedImage[]>> {
return client.get<GeneratedImage[]>(`/api/generations?favorite=true&limit=${limit}`);
},
/**
* Get generation statistics
*/
async getStats(): Promise<ApiResult<GenerationStats>> {
return client.get<GenerationStats>('/api/stats');
},
/**
* Get total generation count
*/
async getGenerationCount(): Promise<ApiResult<number>> {
const result = await this.getStats();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.totalGenerations, error: null };
},
};

View file

@ -22,6 +22,9 @@
import ChatRecentWidget from './widgets/ChatRecentWidget.svelte';
import ContactsFavoritesWidget from './widgets/ContactsFavoritesWidget.svelte';
import ZitareQuoteWidget from './widgets/ZitareQuoteWidget.svelte';
import PictureRecentWidget from './widgets/PictureRecentWidget.svelte';
import ManadeckProgressWidget from './widgets/ManadeckProgressWidget.svelte';
import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
interface Props {
widget: WidgetConfig;
@ -59,6 +62,9 @@
'chat-recent': ChatRecentWidget,
'contacts-favorites': ContactsFavoritesWidget,
'zitare-quote': ZitareQuoteWidget,
'picture-recent': PictureRecentWidget,
'manadeck-progress': ManadeckProgressWidget,
'clock-timers': ClockTimersWidget,
} as const;
const WidgetComponent = $derived(widgetComponents[widget.type]);

View file

@ -0,0 +1,184 @@
<script lang="ts">
/**
* ClockTimersWidget - Active timers and alarms
*/
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';
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);
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';
if (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 {
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';
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
return days.map((d) => dayNames[d]).join(', ');
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<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="http://localhost:5177"
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>
</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>
</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>
</div>
{/each}
</div>
</div>
{/if}
<div class="mt-3 text-center">
<a
href="http://localhost:5177"
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.clock.open')}
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,155 @@
<script lang="ts">
/**
* ManadeckProgressWidget - Learning progress and due cards
*/
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';
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';
if (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">
<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>
<a
href="http://localhost:5176"
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.manadeck.create_deck')}
</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="http://localhost:5176/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="http://localhost:5176/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

@ -0,0 +1,130 @@
<script lang="ts">
/**
* PictureRecentWidget - Recent AI-generated images
*/
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';
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';
if (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>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🎨</span>
{$_('dashboard.widgets.picture.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.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="http://localhost:5175"
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>
{:else}
<div class="grid grid-cols-3 gap-2">
{#each data as image}
<a
href="http://localhost:5175/gallery/{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}
</a>
{/each}
</div>
<div class="mt-3 text-center">
<a
href="http://localhost:5175/gallery"
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.picture.view_all')}
</a>
</div>
{/if}
</div>

View file

@ -66,6 +66,32 @@
"explore": "Zitate entdecken",
"refresh": "Neues Zitat",
"view_all": "Alle Zitate"
},
"picture": {
"title": "Bilder",
"description": "Letzte KI-Generierungen",
"empty": "Noch keine Bilder erstellt",
"create": "Bild erstellen",
"view_all": "Alle Bilder"
},
"manadeck": {
"title": "Lernfortschritt",
"description": "Deine Lernkarten",
"empty": "Noch keine Decks erstellt",
"create_deck": "Deck erstellen",
"streak": "Tage Serie",
"due": "fällig",
"today": "heute gelernt",
"learned": "Gelernt",
"start_study": "Lernen starten"
},
"clock": {
"title": "Timer & Wecker",
"description": "Aktive Timer und Wecker",
"empty": "Keine aktiven Timer oder Wecker",
"open": "Clock öffnen",
"active_timers": "Aktive Timer",
"alarms": "Wecker"
}
}
},

View file

@ -66,6 +66,32 @@
"explore": "Explore quotes",
"refresh": "New quote",
"view_all": "All quotes"
},
"picture": {
"title": "Images",
"description": "Recent AI generations",
"empty": "No images created yet",
"create": "Create image",
"view_all": "View all images"
},
"manadeck": {
"title": "Learning Progress",
"description": "Your flashcards",
"empty": "No decks created yet",
"create_deck": "Create deck",
"streak": "Day streak",
"due": "due",
"today": "learned today",
"learned": "Learned",
"start_study": "Start studying"
},
"clock": {
"title": "Timers & Alarms",
"description": "Active timers and alarms",
"empty": "No active timers or alarms",
"open": "Open Clock",
"active_timers": "Active Timers",
"alarms": "Alarms"
}
}
},

View file

@ -16,7 +16,10 @@ export type WidgetType =
| 'calendar-events' // Calendar API: upcoming events
| 'chat-recent' // Chat API: recent conversations
| 'contacts-favorites' // Contacts API: favorite contacts
| 'zitare-quote'; // Zitare API: favorite quotes
| 'zitare-quote' // Zitare API: daily inspiration quote
| 'picture-recent' // Picture API: recent generations
| 'manadeck-progress' // ManaDeck API: learning progress
| 'clock-timers'; // Clock: active timers and alarms
/**
* Widget size - maps to CSS Grid columns
@ -101,7 +104,16 @@ export interface WidgetMeta {
/** Whether multiple instances are allowed */
allowMultiple: boolean;
/** Required backend (for status display) */
requiredBackend?: 'todo' | 'calendar' | 'chat' | 'contacts' | 'zitare' | 'mana-core-auth';
requiredBackend?:
| 'todo'
| 'calendar'
| 'chat'
| 'contacts'
| 'zitare'
| 'picture'
| 'manadeck'
| 'clock'
| 'mana-core-auth';
}
/**
@ -188,6 +200,33 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'zitare',
},
{
type: 'picture-recent',
nameKey: 'dashboard.widgets.picture.title',
descriptionKey: 'dashboard.widgets.picture.description',
icon: '🎨',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'picture',
},
{
type: 'manadeck-progress',
nameKey: 'dashboard.widgets.manadeck.title',
descriptionKey: 'dashboard.widgets.manadeck.description',
icon: '🎴',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'manadeck',
},
{
type: 'clock-timers',
nameKey: 'dashboard.widgets.clock.title',
descriptionKey: 'dashboard.widgets.clock.description',
icon: '⏰',
defaultSize: 'small',
allowMultiple: false,
requiredBackend: 'clock',
},
];
/**