mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
acb5d74420
commit
5fd5423f8e
11 changed files with 961 additions and 2 deletions
199
apps/manacore/apps/web/src/lib/api/services/clock.ts
Normal file
199
apps/manacore/apps/web/src/lib/api/services/clock.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
110
apps/manacore/apps/web/src/lib/api/services/manadeck.ts
Normal file
110
apps/manacore/apps/web/src/lib/api/services/manadeck.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
81
apps/manacore/apps/web/src/lib/api/services/picture.ts
Normal file
81
apps/manacore/apps/web/src/lib/api/services/picture.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue