feat(manacore): add configurable cross-app dashboard with widgets

- Add widget system with 9 widget types (credits, tasks, calendar, chat, contacts, quotes, etc.)
- Implement drag-and-drop grid layout with edit mode
- Create API services for todo, calendar, chat, contacts, and zitare backends
- Add dashboard store with localStorage persistence
- Include German and English i18n translations
- Replace legacy dashboard with new configurable widget-based UI
This commit is contained in:
Till-JS 2025-12-04 15:41:24 +01:00
parent 9eb0b51c4c
commit 10f4da819b
27 changed files with 2641 additions and 176 deletions

View file

@ -59,6 +59,7 @@
"@manacore/shared-utils": "workspace:*",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.81.1",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.0"
},
"type": "module"

View file

@ -0,0 +1,157 @@
/**
* Base API Client with Retry Logic
*
* Provides authenticated fetch with exponential backoff retry.
*/
import { authStore } from '$lib/stores/authStore.svelte';
/**
* Retry configuration
*/
export interface RetryConfig {
/** Maximum number of retry attempts (default: 3) */
maxRetries: number;
/** Initial delay in milliseconds (default: 1000) */
retryDelay: number;
/** Multiplier for exponential backoff (default: 2) */
backoffMultiplier: number;
}
/**
* API response wrapper
*/
export interface ApiResult<T> {
data: T | null;
error: string | null;
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
retryDelay: 1000,
backoffMultiplier: 2,
};
/**
* Sleep utility
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Fetch with authentication and retry logic
*
* @param url - Full URL to fetch
* @param options - Fetch options (optional)
* @param retryConfig - Retry configuration (optional)
* @returns Promise with data or error
*/
export async function fetchWithRetry<T>(
url: string,
options: RequestInit = {},
retryConfig: Partial<RetryConfig> = {}
): Promise<ApiResult<T>> {
const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
let lastError: string | null = null;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
// Get fresh token for each attempt
const token = await authStore.getAccessToken();
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!response.ok) {
// Don't retry on auth errors
if (response.status === 401 || response.status === 403) {
return {
data: null,
error: `Authentication failed (${response.status})`,
};
}
// Don't retry on client errors (except rate limiting)
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
const errorBody = await response.json().catch(() => ({ message: 'Request failed' }));
return {
data: null,
error: errorBody.message || `HTTP ${response.status}`,
};
}
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return { data, error: null };
} catch (e) {
lastError = e instanceof Error ? e.message : 'Unknown error';
// Don't retry on last attempt
if (attempt < config.maxRetries) {
const delay = config.retryDelay * Math.pow(config.backoffMultiplier, attempt);
console.warn(`API request failed, retrying in ${delay}ms...`, {
url,
attempt,
error: lastError,
});
await sleep(delay);
}
}
}
return { data: null, error: lastError };
}
/**
* Create an API client for a specific backend
*/
export function createApiClient(baseUrl: string) {
return {
get<T>(endpoint: string, retryConfig?: Partial<RetryConfig>): Promise<ApiResult<T>> {
return fetchWithRetry<T>(`${baseUrl}${endpoint}`, { method: 'GET' }, retryConfig);
},
post<T>(
endpoint: string,
body?: unknown,
retryConfig?: Partial<RetryConfig>
): Promise<ApiResult<T>> {
return fetchWithRetry<T>(
`${baseUrl}${endpoint}`,
{
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
},
retryConfig
);
},
put<T>(
endpoint: string,
body?: unknown,
retryConfig?: Partial<RetryConfig>
): Promise<ApiResult<T>> {
return fetchWithRetry<T>(
`${baseUrl}${endpoint}`,
{
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
},
retryConfig
);
},
delete<T>(endpoint: string, retryConfig?: Partial<RetryConfig>): Promise<ApiResult<T>> {
return fetchWithRetry<T>(`${baseUrl}${endpoint}`, { method: 'DELETE' }, retryConfig);
},
};
}

View file

@ -0,0 +1,94 @@
/**
* Calendar API Service
*
* Fetches events from the Calendar backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CALENDAR_API_URL = import.meta.env.PUBLIC_CALENDAR_API_URL || 'http://localhost:3014';
const client = createApiClient(CALENDAR_API_URL);
/**
* Calendar entity from Calendar backend
*/
export interface Calendar {
id: string;
userId: string;
name: string;
description?: string;
color: string;
isDefault: boolean;
isVisible: boolean;
timezone: string;
createdAt: string;
updatedAt: string;
}
/**
* Event entity from Calendar backend
*/
export interface CalendarEvent {
id: string;
calendarId: string;
userId: string;
title: string;
description?: string;
location?: string;
startTime: string;
endTime: string;
isAllDay: boolean;
timezone: string;
recurrenceRule?: string;
color?: string;
status: 'confirmed' | 'tentative' | 'cancelled';
createdAt: string;
updatedAt: string;
}
/**
* Calendar service for dashboard widgets
*/
export const calendarService = {
/**
* Get upcoming events for the next N days
*/
async getUpcomingEvents(days: number = 7): Promise<ApiResult<CalendarEvent[]>> {
const startDate = new Date().toISOString().split('T')[0];
const endDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
return client.get<CalendarEvent[]>(`/events?startDate=${startDate}&endDate=${endDate}`);
},
/**
* Get today's events
*/
async getTodayEvents(): Promise<ApiResult<CalendarEvent[]>> {
const today = new Date().toISOString().split('T')[0];
return client.get<CalendarEvent[]>(`/events?startDate=${today}&endDate=${today}`);
},
/**
* Get all calendars
*/
async getCalendars(): Promise<ApiResult<Calendar[]>> {
return client.get<Calendar[]>('/calendars');
},
/**
* Get events for a specific calendar
*/
async getCalendarEvents(
calendarId: string,
days: number = 7
): Promise<ApiResult<CalendarEvent[]>> {
const startDate = new Date().toISOString().split('T')[0];
const endDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
return client.get<CalendarEvent[]>(
`/events?calendarIds=${calendarId}&startDate=${startDate}&endDate=${endDate}`
);
},
};

View file

@ -0,0 +1,110 @@
/**
* Chat API Service
*
* Fetches conversations from the Chat backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CHAT_API_URL = import.meta.env.PUBLIC_CHAT_API_URL || 'http://localhost:3002';
const client = createApiClient(CHAT_API_URL);
/**
* Conversation entity from Chat backend
*/
export interface Conversation {
id: string;
userId: string;
title: string;
modelId: string;
spaceId?: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
isPinned: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Message entity from Chat backend
*/
export interface Message {
id: string;
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
createdAt: string;
}
/**
* AI Model entity from Chat backend
*/
export interface AiModel {
id: string;
name: string;
description: string;
}
/**
* Chat service for dashboard widgets
*/
export const chatService = {
/**
* Get recent conversations
*/
async getRecentConversations(limit: number = 5): Promise<ApiResult<Conversation[]>> {
const result = await client.get<Conversation[]>('/conversations');
if (result.error || !result.data) {
return result;
}
// Sort by updatedAt and limit
const sorted = result.data
.filter((c) => !c.isArchived)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, limit);
return { data: sorted, error: null };
},
/**
* Get pinned conversations
*/
async getPinnedConversations(): Promise<ApiResult<Conversation[]>> {
const result = await client.get<Conversation[]>('/conversations');
if (result.error || !result.data) {
return result;
}
const pinned = result.data.filter((c) => c.isPinned && !c.isArchived);
return { data: pinned, error: null };
},
/**
* Get available AI models
*/
async getModels(): Promise<ApiResult<AiModel[]>> {
return client.get<AiModel[]>('/chat/models');
},
/**
* Get conversation count
*/
async getConversationCount(): Promise<ApiResult<{ total: number; pinned: number }>> {
const result = await client.get<Conversation[]>('/conversations');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
const active = result.data.filter((c) => !c.isArchived);
const pinned = active.filter((c) => c.isPinned);
return { data: { total: active.length, pinned: pinned.length }, error: null };
},
};

View file

@ -0,0 +1,139 @@
/**
* Contacts API Service
*
* Fetches contacts from the Contacts backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CONTACTS_API_URL = import.meta.env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
const client = createApiClient(CONTACTS_API_URL);
/**
* Contact entity from Contacts backend
*/
export interface Contact {
id: string;
userId: string;
firstName?: string;
lastName?: string;
displayName?: string;
nickname?: string;
email?: string;
phone?: string;
mobile?: string;
company?: string;
jobTitle?: string;
birthday?: string;
notes?: string;
isFavorite: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Activity entity from Contacts backend
*/
export interface ContactActivity {
id: string;
contactId: string;
userId: string;
activityType: 'created' | 'updated' | 'called' | 'emailed' | 'met' | 'note_added';
description?: string;
metadata?: Record<string, unknown>;
createdAt: string;
}
/**
* Contacts service for dashboard widgets
*/
export const contactsService = {
/**
* Get favorite contacts
*/
async getFavoriteContacts(limit: number = 5): Promise<ApiResult<Contact[]>> {
const result = await client.get<Contact[]>(`/contacts?isFavorite=true&limit=${limit}`);
return result;
},
/**
* Get recent contacts (by updatedAt)
*/
async getRecentContacts(limit: number = 5): Promise<ApiResult<Contact[]>> {
const result = await client.get<Contact[]>(`/contacts?limit=${limit}`);
if (result.error || !result.data) {
return result;
}
// Sort by updatedAt and filter archived
const sorted = result.data
.filter((c) => !c.isArchived)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, limit);
return { data: sorted, error: null };
},
/**
* Get contacts with upcoming birthdays
*/
async getUpcomingBirthdays(days: number = 30): Promise<ApiResult<Contact[]>> {
const result = await client.get<Contact[]>('/contacts');
if (result.error || !result.data) {
return result;
}
const today = new Date();
const futureDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
const withBirthdays = result.data.filter((c) => {
if (!c.birthday || c.isArchived) return false;
const birthday = new Date(c.birthday);
// Set birthday to this year
birthday.setFullYear(today.getFullYear());
// If birthday already passed this year, check next year
if (birthday < today) {
birthday.setFullYear(today.getFullYear() + 1);
}
return birthday >= today && birthday <= futureDate;
});
return { data: withBirthdays, error: null };
},
/**
* Get contact count
*/
async getContactCount(): Promise<ApiResult<{ total: number; favorites: number }>> {
const result = await client.get<Contact[]>('/contacts');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
const active = result.data.filter((c) => !c.isArchived);
const favorites = active.filter((c) => c.isFavorite);
return { data: { total: active.length, favorites: favorites.length }, error: null };
},
/**
* Get display name for a contact
*/
getDisplayName(contact: Contact): string {
if (contact.displayName) return contact.displayName;
if (contact.firstName && contact.lastName) return `${contact.firstName} ${contact.lastName}`;
if (contact.firstName) return contact.firstName;
if (contact.lastName) return contact.lastName;
if (contact.email) return contact.email;
return 'Unknown';
},
};

View file

@ -0,0 +1,11 @@
/**
* Dashboard API Services
*
* Re-exports all app-specific services for the dashboard.
*/
export { todoService, type Task, type Project } from './todo';
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';

View file

@ -0,0 +1,95 @@
/**
* Todo API Service
*
* Fetches tasks from the Todo backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const TODO_API_URL = import.meta.env.PUBLIC_TODO_API_URL || 'http://localhost:3017';
const client = createApiClient(TODO_API_URL);
/**
* Task entity from Todo backend
*/
export interface Task {
id: string;
title: string;
description?: string;
projectId?: string | null;
priority: 'low' | 'medium' | 'high' | 'urgent';
dueDate?: string;
dueTime?: string;
isCompleted: boolean;
status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
labelIds: string[];
createdAt: string;
updatedAt: string;
}
/**
* Project entity from Todo backend
*/
export interface Project {
id: string;
name: string;
color: string;
icon?: string;
order: number;
isArchived: boolean;
}
/**
* Todo service for dashboard widgets
*/
export const todoService = {
/**
* Get today's tasks
*/
async getTodayTasks(): Promise<ApiResult<Task[]>> {
return client.get<Task[]>('/tasks/today');
},
/**
* Get upcoming tasks for the next N days
*/
async getUpcomingTasks(days: number = 7): Promise<ApiResult<Task[]>> {
return client.get<Task[]>(`/tasks/upcoming?days=${days}`);
},
/**
* Get inbox tasks (unassigned to project)
*/
async getInboxTasks(): Promise<ApiResult<Task[]>> {
return client.get<Task[]>('/tasks/inbox');
},
/**
* Get all projects
*/
async getProjects(): Promise<ApiResult<Project[]>> {
return client.get<Project[]>('/projects');
},
/**
* Get task count summary
*/
async getTaskCounts(): Promise<ApiResult<{ today: number; upcoming: number; overdue: number }>> {
// This might need a dedicated endpoint - for now we fetch and count
const todayResult = await this.getTodayTasks();
const upcomingResult = await this.getUpcomingTasks();
if (todayResult.error || upcomingResult.error) {
return { data: null, error: todayResult.error || upcomingResult.error };
}
const today = todayResult.data?.length || 0;
const upcoming = upcomingResult.data?.length || 0;
const overdue =
todayResult.data?.filter((t) => t.dueDate && new Date(t.dueDate) < new Date()).length || 0;
return { data: { today, upcoming, overdue }, error: null };
},
};

View file

@ -0,0 +1,92 @@
/**
* Zitare API Service
*
* Fetches favorite quotes from the Zitare backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const ZITARE_API_URL = import.meta.env.PUBLIC_ZITARE_API_URL || 'http://localhost:3007';
const client = createApiClient(ZITARE_API_URL);
/**
* Favorite entity from Zitare backend
*/
export interface Favorite {
id: string;
userId: string;
quoteId: string;
createdAt: string;
}
/**
* Quote data (may need to be enriched from a quotes API)
*/
export interface Quote {
id: string;
text: string;
author?: string;
source?: string;
category?: string;
}
/**
* List entity from Zitare backend
*/
export interface QuoteList {
id: string;
userId: string;
name: string;
description?: string;
quoteIds: string[];
createdAt: string;
updatedAt: string;
}
/**
* Zitare service for dashboard widgets
*/
export const zitareService = {
/**
* Get user's favorite quotes
*/
async getFavorites(): Promise<ApiResult<Favorite[]>> {
return client.get<Favorite[]>('/favorites');
},
/**
* Get a random favorite quote
*/
async getRandomFavorite(): Promise<ApiResult<Favorite | null>> {
const result = await this.getFavorites();
if (result.error || !result.data || result.data.length === 0) {
return { data: null, error: result.error || 'No favorites found' };
}
const randomIndex = Math.floor(Math.random() * result.data.length);
return { data: result.data[randomIndex], error: null };
},
/**
* Get user's quote lists
*/
async getLists(): Promise<ApiResult<QuoteList[]>> {
return client.get<QuoteList[]>('/lists');
},
/**
* Get favorite count
*/
async getFavoriteCount(): Promise<ApiResult<number>> {
const result = await this.getFavorites();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.length, error: null };
},
};

View file

@ -0,0 +1,59 @@
<script lang="ts">
/**
* DashboardGrid - Main grid component with drag-and-drop support
*
* Uses svelte-dnd-action for drag-and-drop functionality.
*/
import { dndzone, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import type { WidgetConfig } from '$lib/types/dashboard';
import { dashboardStore } from '$lib/stores/dashboard.svelte';
import WidgetContainer from './WidgetContainer.svelte';
const flipDurationMs = 300;
// Local copy of widgets for DnD
let items = $state<WidgetConfig[]>([]);
// Sync with store
$effect(() => {
items = [...dashboardStore.widgets];
});
function handleConsider(e: CustomEvent<DndEvent<WidgetConfig>>) {
items = e.detail.items;
}
function handleFinalize(e: CustomEvent<DndEvent<WidgetConfig>>) {
items = e.detail.items;
dashboardStore.updateWidgets(items);
dashboardStore.persist();
}
</script>
<div
class="grid grid-cols-12 gap-4"
use:dndzone={{
items,
flipDurationMs,
dragDisabled: !dashboardStore.isEditing,
dropTargetStyle: {},
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each items as widget (widget.id)}
<div animate:flip={{ duration: flipDurationMs }}>
<WidgetContainer {widget} />
</div>
{/each}
</div>
{#if items.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 text-5xl">📊</div>
<h3 class="mb-2 text-lg font-medium">Keine Widgets</h3>
<p class="text-muted-foreground">Klicke auf "Anpassen" um Widgets hinzuzufügen.</p>
</div>
{/if}

View file

@ -0,0 +1,138 @@
<script lang="ts">
/**
* WidgetContainer - Wrapper component for dashboard widgets
*
* Provides edit mode controls (drag handle, resize, remove) and
* renders the appropriate widget component based on type.
*/
import { _ } from 'svelte-i18n';
import { Card } from '@manacore/shared-ui';
import type { WidgetConfig, WidgetSize } from '$lib/types/dashboard';
import { WIDGET_SIZE_CLASSES, getWidgetMeta } from '$lib/types/dashboard';
import { dashboardStore } from '$lib/stores/dashboard.svelte';
// Widget components
import CreditsWidget from './widgets/CreditsWidget.svelte';
import QuickActionsWidget from './widgets/QuickActionsWidget.svelte';
import TransactionsWidget from './widgets/TransactionsWidget.svelte';
import TasksTodayWidget from './widgets/TasksTodayWidget.svelte';
import TasksUpcomingWidget from './widgets/TasksUpcomingWidget.svelte';
import CalendarEventsWidget from './widgets/CalendarEventsWidget.svelte';
import ChatRecentWidget from './widgets/ChatRecentWidget.svelte';
import ContactsFavoritesWidget from './widgets/ContactsFavoritesWidget.svelte';
import ZitareQuoteWidget from './widgets/ZitareQuoteWidget.svelte';
interface Props {
widget: WidgetConfig;
}
let { widget }: Props = $props();
const meta = $derived(getWidgetMeta(widget.type));
const sizeClasses = $derived(WIDGET_SIZE_CLASSES[widget.size]);
const sizes: WidgetSize[] = ['small', 'medium', 'large', 'full'];
const sizeLabels: Record<WidgetSize, string> = {
small: 'S',
medium: 'M',
large: 'L',
full: 'XL',
};
function handleSizeChange(size: WidgetSize) {
dashboardStore.updateWidgetSize(widget.id, size);
}
function handleRemove() {
dashboardStore.removeWidget(widget.id);
}
// Widget component mapping
const widgetComponents = {
credits: CreditsWidget,
'quick-actions': QuickActionsWidget,
transactions: TransactionsWidget,
'tasks-today': TasksTodayWidget,
'tasks-upcoming': TasksUpcomingWidget,
'calendar-events': CalendarEventsWidget,
'chat-recent': ChatRecentWidget,
'contacts-favorites': ContactsFavoritesWidget,
'zitare-quote': ZitareQuoteWidget,
} as const;
const WidgetComponent = $derived(widgetComponents[widget.type]);
</script>
<div class={sizeClasses}>
<Card class="relative h-full">
<!-- Edit Mode Overlay -->
{#if dashboardStore.isEditing}
<div
class="absolute inset-0 z-10 flex flex-col rounded-xl border-2 border-dashed border-primary/50 bg-background/80"
>
<!-- Drag Handle -->
<div
class="flex cursor-grab items-center justify-center gap-2 border-b border-border py-2 active:cursor-grabbing"
>
<svg class="h-5 w-5 text-muted-foreground" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" />
<circle cx="15" cy="5" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="19" r="1.5" />
</svg>
<span class="text-sm font-medium">{meta?.icon} {$_(widget.title)}</span>
</div>
<!-- Size Buttons -->
<div class="flex flex-1 items-center justify-center gap-2 p-4">
{#each sizes as size}
<button
type="button"
onclick={() => handleSizeChange(size)}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {widget.size ===
size
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
>
{sizeLabels[size]}
</button>
{/each}
</div>
<!-- Remove Button -->
<div class="flex justify-center border-t border-border py-2">
<button
type="button"
onclick={handleRemove}
class="flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
{$_('dashboard.remove_widget')}
</button>
</div>
</div>
{/if}
<!-- Widget Content -->
<div class="p-4" class:opacity-0={dashboardStore.isEditing}>
{#if WidgetComponent}
<WidgetComponent />
{:else}
<p class="text-muted-foreground">Unknown widget type: {widget.type}</p>
{/if}
</div>
</Card>
</div>

View file

@ -0,0 +1,52 @@
<script lang="ts">
/**
* WidgetError - Error state for widgets
*
* Displays error message with retry button.
*/
import { _ } from 'svelte-i18n';
interface Props {
/** Error message to display */
error: string | null;
/** Callback when retry is clicked */
onRetry: () => void;
/** Whether a retry is currently in progress */
retrying?: boolean;
}
let { error, onRetry, retrying = false }: Props = $props();
</script>
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-3 text-3xl">⚠️</div>
<p class="mb-4 text-sm text-muted-foreground">
{error || $_('dashboard.widget_error')}
</p>
<button
type="button"
onclick={onRetry}
disabled={retrying}
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{#if retrying}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{$_('common.loading')}
{:else}
<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>
{$_('dashboard.retry')}
{/if}
</button>
</div>

View file

@ -0,0 +1,37 @@
<script lang="ts">
/**
* WidgetSkeleton - Loading state for widgets
*
* Displays animated skeleton placeholders while widget data is loading.
*/
interface Props {
/** Number of skeleton lines to show */
lines?: number;
/** Show a header skeleton */
showHeader?: boolean;
}
let { lines = 3, showHeader = false }: Props = $props();
</script>
<div class="animate-pulse space-y-3">
{#if showHeader}
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-lg bg-muted"></div>
<div class="h-5 w-32 rounded bg-muted"></div>
</div>
{/if}
{#each Array(lines) as _, i}
<div class="flex items-center gap-3">
{#if i === 0}
<div class="h-4 w-3/4 rounded bg-muted"></div>
{:else if i === lines - 1}
<div class="h-4 w-1/2 rounded bg-muted"></div>
{:else}
<div class="h-4 w-full rounded bg-muted"></div>
{/if}
</div>
{/each}
</div>

View file

@ -0,0 +1,130 @@
<script lang="ts">
/**
* CalendarEventsWidget - Upcoming calendar events
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { calendarService, type CalendarEvent } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<CalendarEvent[]>([]);
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 calendarService.getUpcomingEvents(7);
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 formatEventTime(event: CalendarEvent): string {
const start = new Date(event.startTime);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
let dateStr = '';
if (start.toDateString() === today.toDateString()) {
dateStr = 'Heute';
} else if (start.toDateString() === tomorrow.toDateString()) {
dateStr = 'Morgen';
} else {
dateStr = start.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
if (event.isAllDay) {
return dateStr;
}
const timeStr = start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return `${dateStr}, ${timeStr}`;
}
const displayedEvents = $derived(data.slice(0, MAX_DISPLAY));
const remainingCount = $derived(Math.max(0, data.length - MAX_DISPLAY));
</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.calendar.title')}
</h3>
{#if data.length > 0}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-sm font-medium text-primary">
{data.length}
</span>
{/if}
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{: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.calendar.empty')}
</p>
</div>
{:else}
<div class="space-y-2">
{#each displayedEvents as event}
<div class="flex items-start gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover">
<div
class="mt-1 h-3 w-3 flex-shrink-0 rounded-full"
style="background-color: {event.color || '#3B82F6'}"
></div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{event.title}</p>
<p class="text-xs text-muted-foreground">{formatEventTime(event)}</p>
{#if event.location}
<p class="truncate text-xs text-muted-foreground">📍 {event.location}</p>
{/if}
</div>
</div>
{/each}
{#if remainingCount > 0}
<a
href="http://localhost:5179"
target="_blank"
rel="noopener"
class="block rounded-lg py-2 text-center text-sm text-primary hover:bg-primary/5"
>
+{remainingCount} weitere
</a>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,117 @@
<script lang="ts">
/**
* ChatRecentWidget - Recent AI conversations
*/
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';
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';
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' });
}
</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.chat.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{: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.chat.empty')}
</p>
<a
href="http://localhost:5174"
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>
{:else}
<div class="space-y-2">
{#each data as conversation}
<a
href="http://localhost:5174/chat/{conversation.id}"
target="_blank"
rel="noopener"
class="flex items-center gap-3 rounded-lg p-2 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>
</div>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,133 @@
<script lang="ts">
/**
* ContactsFavoritesWidget - Favorite contacts
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { contactsService, type Contact } from '$lib/api/services';
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<Contact[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const MAX_DISPLAY = 5;
// Determine app URL based on environment
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const contactsUrl = isDev ? APP_URLS.contacts.dev : APP_URLS.contacts.prod;
async function load() {
state = 'loading';
retrying = true;
const result = await contactsService.getFavoriteContacts(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 getDisplayName(contact: Contact): string {
return contactsService.getDisplayName(contact);
}
function getInitials(contact: Contact): string {
const name = getDisplayName(contact);
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>=e</span>
{$_('dashboard.widgets.contacts.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{: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.contacts.empty')}
</p>
<a
href={contactsUrl}
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.contacts.add_favorites')}
</a>
</div>
{:else}
<div class="space-y-2">
{#each data as contact}
<a
href="{contactsUrl}/contacts/{contact.id}"
target="_blank"
rel="noopener"
class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary"
>
{getInitials(contact)}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{getDisplayName(contact)}
</p>
{#if contact.email}
<p class="truncate text-xs text-muted-foreground">
{contact.email}
</p>
{:else if contact.company}
<p class="truncate text-xs text-muted-foreground">
{contact.company}
</p>
{/if}
</div>
<span class="text-amber-500">P</span>
</a>
{/each}
</div>
<a
href={contactsUrl}
target="_blank"
rel="noopener"
class="mt-3 block text-center text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.contacts.view_all')}
</a>
{/if}
</div>

View file

@ -0,0 +1,68 @@
<script lang="ts">
/**
* CreditsWidget - Displays credit balance and stats
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { creditsService, type CreditBalance } from '$lib/api/credits';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<CreditBalance | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
async function load() {
state = 'loading';
retrying = true;
try {
const balance = await creditsService.getBalance();
data = balance;
state = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load credits';
state = 'error';
} finally {
retrying = false;
}
}
onMount(load);
function formatCredits(amount: number): string {
return amount.toLocaleString('de-DE');
}
</script>
<div>
<h3 class="mb-3 flex items-center gap-2 text-lg font-semibold">
<span>💰</span>
{$_('dashboard.widgets.credits.title')}
</h3>
{#if state === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">{$_('dashboard.widgets.credits.available')}</span>
<span class="text-2xl font-bold">{formatCredits(data.balance)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">{$_('dashboard.widgets.credits.free_today')}</span>
<span class="font-medium">{data.freeCreditsRemaining}/{data.dailyFreeCredits}</span>
</div>
<a
href="/credits"
class="mt-4 block w-full rounded-lg bg-primary/10 py-2 text-center text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.credits.manage')}
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,49 @@
<script lang="ts">
/**
* QuickActionsWidget - Quick action links
*/
import { _ } from 'svelte-i18n';
const actions = [
{
href: '/credits',
icon: '💰',
labelKey: 'dashboard.widgets.quick_actions.credits',
descKey: 'dashboard.widgets.quick_actions.credits_desc',
},
{
href: '/feedback',
icon: '💬',
labelKey: 'dashboard.widgets.quick_actions.feedback',
descKey: 'dashboard.widgets.quick_actions.feedback_desc',
},
{
href: '/profile',
icon: '👤',
labelKey: 'dashboard.widgets.quick_actions.profile',
descKey: 'dashboard.widgets.quick_actions.profile_desc',
},
];
</script>
<div>
<h3 class="mb-3 flex items-center gap-2 text-lg font-semibold">
<span></span>
{$_('dashboard.widgets.quick_actions.title')}
</h3>
<div class="space-y-2">
{#each actions as action}
<a href={action.href} class="block rounded-lg p-3 transition-colors hover:bg-surface-hover">
<div class="flex items-center">
<span class="text-2xl">{action.icon}</span>
<div class="ml-3">
<p class="font-medium">{$_(action.labelKey)}</p>
<p class="text-sm text-muted-foreground">{$_(action.descKey)}</p>
</div>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,140 @@
<script lang="ts">
/**
* TasksTodayWidget - Today's tasks from Todo app
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { todoService, type Task } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<Task[]>([]);
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 todoService.getTodayTasks();
if (result.data) {
data = result.data;
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
// Auto-retry up to 3 times
if (retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function getPriorityColor(priority: string): string {
switch (priority) {
case 'urgent':
return 'text-red-500';
case 'high':
return 'text-orange-500';
case 'medium':
return 'text-yellow-500';
default:
return 'text-muted-foreground';
}
}
const displayedTasks = $derived(data.slice(0, MAX_DISPLAY));
const remainingCount = $derived(Math.max(0, data.length - MAX_DISPLAY));
</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.tasks_today.title')}
</h3>
{#if data.length > 0}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-sm font-medium text-primary">
{data.length}
</span>
{/if}
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{: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.tasks_today.empty')}
</p>
</div>
{:else}
<div class="space-y-2">
{#each displayedTasks as task}
<div
class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div
class="h-4 w-4 rounded border-2 {task.isCompleted
? 'border-primary bg-primary'
: 'border-muted-foreground'}"
>
{#if task.isCompleted}
<svg
class="h-full w-full text-primary-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<path d="M20 6L9 17l-5-5" />
</svg>
{/if}
</div>
<div class="min-w-0 flex-1">
<p
class="truncate text-sm font-medium {task.isCompleted
? 'text-muted-foreground line-through'
: ''}"
>
{task.title}
</p>
{#if task.dueTime}
<p class="text-xs text-muted-foreground">{task.dueTime}</p>
{/if}
</div>
{#if !task.isCompleted && task.priority !== 'low'}
<span class={getPriorityColor(task.priority)}>●</span>
{/if}
</div>
{/each}
{#if remainingCount > 0}
<a
href="http://localhost:5188"
target="_blank"
rel="noopener"
class="block rounded-lg py-2 text-center text-sm text-primary hover:bg-primary/5"
>
{$_('dashboard.widgets.tasks_today.view_all', { values: { count: remainingCount } })}
</a>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,116 @@
<script lang="ts">
/**
* TasksUpcomingWidget - Upcoming tasks for the next 7 days
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { todoService, type Task } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<Task[]>([]);
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 todoService.getUpcomingTasks(7);
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 tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
return 'Heute';
}
if (date.toDateString() === tomorrow.toDateString()) {
return 'Morgen';
}
return date.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short' });
}
const displayedTasks = $derived(data.slice(0, MAX_DISPLAY));
const remainingCount = $derived(Math.max(0, data.length - MAX_DISPLAY));
</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.tasks_upcoming.title')}
</h3>
{#if data.length > 0}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-sm font-medium text-primary">
{data.length}
</span>
{/if}
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{: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.tasks_upcoming.empty')}
</p>
</div>
{:else}
<div class="space-y-2">
{#each displayedTasks as task}
<div
class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{task.title}</p>
{#if task.dueDate}
<p class="text-xs text-muted-foreground">{formatDate(task.dueDate)}</p>
{/if}
</div>
</div>
{/each}
{#if remainingCount > 0}
<a
href="http://localhost:5188"
target="_blank"
rel="noopener"
class="block rounded-lg py-2 text-center text-sm text-primary hover:bg-primary/5"
>
+{remainingCount} weitere
</a>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,98 @@
<script lang="ts">
/**
* TransactionsWidget - Recent credit transactions
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { creditsService, type CreditTransaction } from '$lib/api/credits';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<CreditTransaction[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
async function load() {
state = 'loading';
retrying = true;
try {
const transactions = await creditsService.getTransactions(5);
data = transactions;
state = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load transactions';
state = 'error';
} finally {
retrying = false;
}
}
onMount(load);
function formatCredits(amount: number): string {
return amount.toLocaleString('de-DE');
}
function getTransactionIcon(type: string): string {
switch (type) {
case 'purchase':
return '💳';
case 'usage':
return '⚡';
case 'bonus':
return '🎁';
case 'refund':
return '↩️';
default:
return '📝';
}
}
</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.transactions.title')}
</h3>
<a href="/credits?tab=transactions" class="text-sm text-primary hover:underline">
{$_('common.view_all')}
</a>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
<p class="py-4 text-center text-sm text-muted-foreground">
{$_('dashboard.widgets.transactions.empty')}
</p>
{:else}
<div class="space-y-3">
{#each data as tx}
<div class="flex items-center justify-between border-b border-border py-2 last:border-0">
<div class="flex items-center gap-3">
<span class="text-xl">{getTransactionIcon(tx.type)}</span>
<div>
<p class="text-sm font-medium">{tx.description || tx.type}</p>
<p class="text-xs text-muted-foreground">
{new Date(tx.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
</div>
<span
class="font-semibold {tx.amount > 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'}"
>
{tx.amount > 0 ? '+' : ''}{formatCredits(tx.amount)}
</span>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,115 @@
<script lang="ts">
/**
* ZitareQuoteWidget - Random inspiring quote from favorites
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { zitareService, type Favorite } from '$lib/api/services';
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);
// 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';
if (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">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<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}
<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>
<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')}
</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')}
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,73 @@
/**
* Default Dashboard Configuration
*
* Provides the initial widget layout for new users.
*/
import type { DashboardConfig } from '$lib/types/dashboard';
/**
* Default dashboard configuration with 6 widgets in a 2-column layout
*/
export const DEFAULT_DASHBOARD_CONFIG: DashboardConfig = {
widgets: [
// Row 0: Credits and Tasks Today
{
id: 'credits-1',
type: 'credits',
title: 'dashboard.widgets.credits.title',
size: 'medium',
position: { x: 0, y: 0 },
visible: true,
},
{
id: 'tasks-today-1',
type: 'tasks-today',
title: 'dashboard.widgets.tasks_today.title',
size: 'medium',
position: { x: 6, y: 0 },
visible: true,
},
// Row 1: Calendar and Quick Actions
{
id: 'calendar-events-1',
type: 'calendar-events',
title: 'dashboard.widgets.calendar.title',
size: 'medium',
position: { x: 0, y: 1 },
visible: true,
},
{
id: 'quick-actions-1',
type: 'quick-actions',
title: 'dashboard.widgets.quick_actions.title',
size: 'medium',
position: { x: 6, y: 1 },
visible: true,
},
// Row 2: Chat and Contacts
{
id: 'chat-recent-1',
type: 'chat-recent',
title: 'dashboard.widgets.chat.title',
size: 'medium',
position: { x: 0, y: 2 },
visible: true,
},
{
id: 'contacts-favorites-1',
type: 'contacts-favorites',
title: 'dashboard.widgets.contacts.title',
size: 'medium',
position: { x: 6, y: 2 },
visible: true,
},
],
gridColumns: 12,
lastModified: new Date().toISOString(),
};
/**
* LocalStorage key for dashboard configuration
*/
export const DASHBOARD_STORAGE_KEY = 'manacore-dashboard-config';

View file

@ -6,6 +6,75 @@
"back": "Zurück",
"loading": "Lädt..."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Willkommen zurück",
"customize": "Anpassen",
"done": "Fertig",
"no_data": "Keine Daten verfügbar",
"retry": "Erneut versuchen",
"widget_error": "Fehler beim Laden",
"remove_widget": "Entfernen",
"widgets": {
"credits": {
"title": "Credits",
"description": "Dein Kontostand",
"available": "Verfügbar",
"free_today": "Gratis heute",
"manage": "Verwalten"
},
"quick_actions": {
"title": "Schnellzugriff",
"description": "Schnelle Aktionen"
},
"transactions": {
"title": "Transaktionen",
"description": "Letzte Aktivitäten",
"empty": "Keine Transaktionen"
},
"tasks_today": {
"title": "Aufgaben heute",
"description": "Deine heutigen Aufgaben",
"empty": "Keine Aufgaben für heute"
},
"tasks_upcoming": {
"title": "Kommende Aufgaben",
"description": "Die nächsten 7 Tage",
"empty": "Keine kommenden Aufgaben"
},
"calendar": {
"title": "Kalender",
"description": "Anstehende Termine",
"empty": "Keine Termine"
},
"chat": {
"title": "Chat",
"description": "Letzte Unterhaltungen",
"empty": "Keine Unterhaltungen"
},
"contacts": {
"title": "Kontakte",
"description": "Deine Favoriten",
"empty": "Keine Favoriten",
"add_favorites": "Favoriten hinzufügen",
"view_all": "Alle anzeigen"
},
"zitare": {
"title": "Inspiration",
"description": "Zitat des Tages",
"empty": "Keine Favoriten",
"explore": "Zitate entdecken",
"refresh": "Neues Zitat",
"view_all": "Alle Zitate"
}
}
},
"credits": {
"available": "Verfügbar",
"daily_free": "Gratis heute",
"total_spent": "Verbraucht",
"manage": "Credits verwalten"
},
"app_slider": {
"title": "Teil des Mana Ökosystems",
"memoro_desc": "KI-gestützte Sprachnotizen",

View file

@ -6,6 +6,75 @@
"back": "Back",
"loading": "Loading..."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
"customize": "Customize",
"done": "Done",
"no_data": "No data available",
"retry": "Try again",
"widget_error": "Failed to load",
"remove_widget": "Remove",
"widgets": {
"credits": {
"title": "Credits",
"description": "Your balance",
"available": "Available",
"free_today": "Free today",
"manage": "Manage"
},
"quick_actions": {
"title": "Quick Actions",
"description": "Quick access"
},
"transactions": {
"title": "Transactions",
"description": "Recent activity",
"empty": "No transactions"
},
"tasks_today": {
"title": "Tasks Today",
"description": "Your tasks for today",
"empty": "No tasks for today"
},
"tasks_upcoming": {
"title": "Upcoming Tasks",
"description": "Next 7 days",
"empty": "No upcoming tasks"
},
"calendar": {
"title": "Calendar",
"description": "Upcoming events",
"empty": "No events"
},
"chat": {
"title": "Chat",
"description": "Recent conversations",
"empty": "No conversations"
},
"contacts": {
"title": "Contacts",
"description": "Your favorites",
"empty": "No favorites",
"add_favorites": "Add favorites",
"view_all": "View all"
},
"zitare": {
"title": "Inspiration",
"description": "Quote of the day",
"empty": "No favorites",
"explore": "Explore quotes",
"refresh": "New quote",
"view_all": "All quotes"
}
}
},
"credits": {
"available": "Available",
"daily_free": "Free today",
"total_spent": "Spent",
"manage": "Manage credits"
},
"app_slider": {
"title": "Part of the Mana Ecosystem",
"memoro_desc": "AI-powered voice notes",

View file

@ -0,0 +1,223 @@
/**
* Dashboard Store - Manages dashboard configuration using Svelte 5 runes
*
* Handles widget layout, edit mode, and persistence to localStorage.
*/
import { browser } from '$app/environment';
import type { DashboardConfig, WidgetConfig, WidgetSize, WidgetType } from '$lib/types/dashboard';
import { DEFAULT_DASHBOARD_CONFIG, DASHBOARD_STORAGE_KEY } from '$lib/config/default-dashboard';
import { getWidgetMeta } from '$lib/types/dashboard';
// State
let config = $state<DashboardConfig>(structuredClone(DEFAULT_DASHBOARD_CONFIG));
let isEditing = $state(false);
let initialized = $state(false);
/**
* Dashboard store with Svelte 5 runes
*/
export const dashboardStore = {
// Getters
get config() {
return config;
},
get widgets() {
return config.widgets.filter((w) => w.visible);
},
get allWidgets() {
return config.widgets;
},
get isEditing() {
return isEditing;
},
get initialized() {
return initialized;
},
/**
* Initialize dashboard from localStorage
*/
initialize() {
if (!browser || initialized) return;
try {
const stored = localStorage.getItem(DASHBOARD_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as DashboardConfig;
// Validate structure
if (parsed.widgets && Array.isArray(parsed.widgets)) {
config = parsed;
}
}
} catch (e) {
console.error('Failed to load dashboard config:', e);
}
initialized = true;
},
/**
* Persist current config to localStorage
*/
persist() {
if (!browser) return;
try {
config.lastModified = new Date().toISOString();
localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(config));
} catch (e) {
console.error('Failed to save dashboard config:', e);
}
},
/**
* Enter edit mode
*/
startEditing() {
isEditing = true;
},
/**
* Exit edit mode and save changes
*/
stopEditing() {
isEditing = false;
this.persist();
},
/**
* Toggle edit mode
*/
toggleEditing() {
if (isEditing) {
this.stopEditing();
} else {
this.startEditing();
}
},
/**
* Update widgets array (called during drag-and-drop)
*/
updateWidgets(widgets: WidgetConfig[]) {
config.widgets = widgets;
},
/**
* Update a single widget's position
*/
updateWidgetPosition(widgetId: string, position: { x: number; y: number }) {
const widget = config.widgets.find((w) => w.id === widgetId);
if (widget) {
widget.position = position;
}
},
/**
* Update a widget's size
*/
updateWidgetSize(widgetId: string, size: WidgetSize) {
const widget = config.widgets.find((w) => w.id === widgetId);
if (widget) {
widget.size = size;
this.persist();
}
},
/**
* Toggle widget visibility
*/
toggleWidgetVisibility(widgetId: string) {
const widget = config.widgets.find((w) => w.id === widgetId);
if (widget) {
widget.visible = !widget.visible;
this.persist();
}
},
/**
* Add a new widget
*/
addWidget(type: WidgetType) {
const meta = getWidgetMeta(type);
if (!meta) return;
// Check if multiple instances are allowed
if (!meta.allowMultiple) {
const existing = config.widgets.find((w) => w.type === type);
if (existing) {
// Just make it visible
existing.visible = true;
this.persist();
return;
}
}
// Generate unique ID
const existingCount = config.widgets.filter((w) => w.type === type).length;
const id = `${type}-${existingCount + 1}`;
// Find the next available row
const maxY = Math.max(...config.widgets.map((w) => w.position.y), -1);
const newWidget: WidgetConfig = {
id,
type,
title: meta.nameKey,
size: meta.defaultSize,
position: { x: 0, y: maxY + 1 },
visible: true,
};
config.widgets = [...config.widgets, newWidget];
this.persist();
},
/**
* Remove a widget
*/
removeWidget(widgetId: string) {
config.widgets = config.widgets.filter((w) => w.id !== widgetId);
this.persist();
},
/**
* Reset to default configuration
*/
resetToDefault() {
config = structuredClone(DEFAULT_DASHBOARD_CONFIG);
this.persist();
},
/**
* Check if a widget type is currently active (visible)
*/
isWidgetActive(type: WidgetType): boolean {
return config.widgets.some((w) => w.type === type && w.visible);
},
/**
* Get available widgets that can be added
*/
getAvailableWidgets(): WidgetType[] {
const activeTypes = new Set(config.widgets.filter((w) => w.visible).map((w) => w.type));
return (
[
'credits',
'quick-actions',
'transactions',
'tasks-today',
'tasks-upcoming',
'calendar-events',
'chat-recent',
'contacts-favorites',
'zitare-quote',
] as WidgetType[]
).filter((type) => {
const meta = getWidgetMeta(type);
// Allow if multiple instances allowed or not currently active
return meta?.allowMultiple || !activeTypes.has(type);
});
},
};

View file

@ -0,0 +1,208 @@
/**
* Dashboard Widget System Types
*
* Defines the type system for the configurable cross-app dashboard.
*/
/**
* Available widget types - each represents a different data source
*/
export type WidgetType =
| 'credits' // Credits balance from mana-core-auth
| 'quick-actions' // Quick action links
| 'transactions' // Recent credit transactions
| 'tasks-today' // Todo API: today's tasks
| 'tasks-upcoming' // Todo API: upcoming 7 days
| 'calendar-events' // Calendar API: upcoming events
| 'chat-recent' // Chat API: recent conversations
| 'contacts-favorites' // Contacts API: favorite contacts
| 'zitare-quote'; // Zitare API: favorite quotes
/**
* Widget size - maps to CSS Grid columns
* - small: 4 cols (1/3 width on desktop)
* - medium: 6 cols (1/2 width on desktop)
* - large: 8 cols (2/3 width on desktop)
* - full: 12 cols (full width)
*/
export type WidgetSize = 'small' | 'medium' | 'large' | 'full';
/**
* Individual widget instance configuration
*/
export interface WidgetConfig {
/** Unique instance ID (e.g., "tasks-today-1") */
id: string;
/** Widget type */
type: WidgetType;
/** i18n key for title (e.g., "dashboard.widgets.credits.title") */
title: string;
/** Grid size */
size: WidgetSize;
/** Grid position */
position: {
/** Column (0-11 for 12-col grid) */
x: number;
/** Row index */
y: number;
};
/** Show/hide toggle */
visible: boolean;
/** Widget-specific settings */
settings?: Record<string, unknown>;
}
/**
* Complete dashboard state
*/
export interface DashboardConfig {
/** List of widget configurations */
widgets: WidgetConfig[];
/** Number of grid columns (default: 12) */
gridColumns: number;
/** ISO timestamp of last modification */
lastModified: string;
}
/**
* Widget loading state
*/
export type WidgetLoadState = 'idle' | 'loading' | 'success' | 'error';
/**
* Generic widget data state wrapper
*/
export interface WidgetDataState<T> {
/** Current loading state */
state: WidgetLoadState;
/** Fetched data (null if not loaded or error) */
data: T | null;
/** Error message (null if no error) */
error: string | null;
/** Number of retry attempts made */
retryCount: number;
/** ISO timestamp of last fetch attempt */
lastFetch: string | null;
}
/**
* Widget metadata for the widget picker
*/
export interface WidgetMeta {
type: WidgetType;
/** i18n key for display name */
nameKey: string;
/** i18n key for description */
descriptionKey: string;
/** Icon identifier */
icon: string;
/** Default size for new instances */
defaultSize: WidgetSize;
/** Whether multiple instances are allowed */
allowMultiple: boolean;
/** Required backend (for status display) */
requiredBackend?: 'todo' | 'calendar' | 'chat' | 'contacts' | 'zitare' | 'mana-core-auth';
}
/**
* Widget registry - metadata for all available widgets
*/
export const WIDGET_REGISTRY: WidgetMeta[] = [
{
type: 'credits',
nameKey: 'dashboard.widgets.credits.title',
descriptionKey: 'dashboard.widgets.credits.description',
icon: '💰',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'mana-core-auth',
},
{
type: 'quick-actions',
nameKey: 'dashboard.widgets.quick_actions.title',
descriptionKey: 'dashboard.widgets.quick_actions.description',
icon: '⚡',
defaultSize: 'medium',
allowMultiple: false,
},
{
type: 'transactions',
nameKey: 'dashboard.widgets.transactions.title',
descriptionKey: 'dashboard.widgets.transactions.description',
icon: '📊',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'mana-core-auth',
},
{
type: 'tasks-today',
nameKey: 'dashboard.widgets.tasks_today.title',
descriptionKey: 'dashboard.widgets.tasks_today.description',
icon: '✅',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'todo',
},
{
type: 'tasks-upcoming',
nameKey: 'dashboard.widgets.tasks_upcoming.title',
descriptionKey: 'dashboard.widgets.tasks_upcoming.description',
icon: '📅',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'todo',
},
{
type: 'calendar-events',
nameKey: 'dashboard.widgets.calendar.title',
descriptionKey: 'dashboard.widgets.calendar.description',
icon: '🗓️',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'calendar',
},
{
type: 'chat-recent',
nameKey: 'dashboard.widgets.chat.title',
descriptionKey: 'dashboard.widgets.chat.description',
icon: '💬',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'chat',
},
{
type: 'contacts-favorites',
nameKey: 'dashboard.widgets.contacts.title',
descriptionKey: 'dashboard.widgets.contacts.description',
icon: '👥',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'contacts',
},
{
type: 'zitare-quote',
nameKey: 'dashboard.widgets.zitare.title',
descriptionKey: 'dashboard.widgets.zitare.description',
icon: '💡',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'zitare',
},
];
/**
* Get widget metadata by type
*/
export function getWidgetMeta(type: WidgetType): WidgetMeta | undefined {
return WIDGET_REGISTRY.find((w) => w.type === type);
}
/**
* Size to Tailwind class mapping
*/
export const WIDGET_SIZE_CLASSES: Record<WidgetSize, string> = {
small: 'col-span-12 sm:col-span-6 lg:col-span-4',
medium: 'col-span-12 lg:col-span-6',
large: 'col-span-12 lg:col-span-8',
full: 'col-span-12',
};

View file

@ -1,188 +1,60 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Card, PageHeader } from '@manacore/shared-ui';
import { creditsService, type CreditBalance, type CreditTransaction } from '$lib/api/credits';
import { _ } from 'svelte-i18n';
import { PageHeader } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/authStore.svelte';
import { dashboardStore } from '$lib/stores/dashboard.svelte';
import DashboardGrid from '$lib/components/dashboard/DashboardGrid.svelte';
let { data } = $props();
let creditBalance = $state<CreditBalance | null>(null);
let recentTransactions = $state<CreditTransaction[]>([]);
let loadingCredits = $state(true);
onMount(async () => {
if (authStore.isAuthenticated) {
try {
const [balance, transactions] = await Promise.all([
creditsService.getBalance(),
creditsService.getTransactions(5),
]);
creditBalance = balance;
recentTransactions = transactions;
} catch (e) {
console.error('Failed to load credits:', e);
} finally {
loadingCredits = false;
}
} else {
loadingCredits = false;
}
onMount(() => {
dashboardStore.initialize();
});
const stats = $derived([
{
name: 'Verfügbare Credits',
value: creditBalance?.balance ?? '...',
icon: '💰',
href: '/credits',
},
{
name: 'Gratis-Credits heute',
value: creditBalance
? `${creditBalance.freeCreditsRemaining}/${creditBalance.dailyFreeCredits}`
: '...',
icon: '🎁',
href: '/credits',
},
{
name: 'Gesamt verbraucht',
value: creditBalance?.totalSpent ?? '...',
icon: '📊',
href: '/credits?tab=transactions',
},
]);
function formatCredits(amount: number): string {
return amount.toLocaleString('de-DE');
}
function getTransactionIcon(type: string): string {
switch (type) {
case 'purchase':
return '💳';
case 'usage':
return '⚡';
case 'bonus':
return '🎁';
default:
return '📝';
}
}
</script>
<div>
<PageHeader
title="Dashboard"
description="Willkommen zurück, {authStore.user?.email || 'User'}"
size="lg"
/>
<!-- Stats Cards -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each stats as stat}
<a href={stat.href} class="block">
<Card>
<div class="flex items-center">
<div class="text-4xl">{stat.icon}</div>
<div class="ml-4 flex-1">
<p class="text-sm font-medium text-muted-foreground">{stat.name}</p>
<p class="mt-1 text-2xl font-semibold">
{#if loadingCredits}
<span class="inline-block w-16 h-6 bg-muted animate-pulse rounded"></span>
{:else}
{typeof stat.value === 'number' ? formatCredits(stat.value) : stat.value}
{/if}
</p>
</div>
</div>
</Card>
</a>
{/each}
</div>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<!-- Quick Actions -->
<Card>
<h2 class="mb-4 text-lg font-semibold">Schnellzugriff</h2>
<div class="space-y-2">
<a href="/credits" class="block rounded-lg p-3 hover:bg-surface-hover transition-colors">
<div class="flex items-center">
<span class="text-2xl">💰</span>
<div class="ml-3">
<p class="font-medium">Credits verwalten</p>
<p class="text-sm text-muted-foreground">Kontostand und Transaktionen</p>
</div>
</div>
</a>
<a href="/feedback" class="block rounded-lg p-3 hover:bg-surface-hover transition-colors">
<div class="flex items-center">
<span class="text-2xl">💬</span>
<div class="ml-3">
<p class="font-medium">Feedback geben</p>
<p class="text-sm text-muted-foreground">Vorschläge und Bug-Reports</p>
</div>
</div>
</a>
<a href="/profile" class="block rounded-lg p-3 hover:bg-surface-hover transition-colors">
<div class="flex items-center">
<span class="text-2xl">👤</span>
<div class="ml-3">
<p class="font-medium">Profil bearbeiten</p>
<p class="text-sm text-muted-foreground">Deine Einstellungen</p>
</div>
</div>
</a>
</div>
</Card>
<!-- Recent Transactions -->
<Card>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Letzte Transaktionen</h2>
<a href="/credits?tab=transactions" class="text-sm text-primary hover:underline">
Alle →
</a>
</div>
{#if loadingCredits}
<div class="space-y-3">
{#each [1, 2, 3] as _}
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-muted animate-pulse rounded"></div>
<div class="flex-1">
<div class="w-24 h-4 bg-muted animate-pulse rounded"></div>
<div class="w-16 h-3 bg-muted animate-pulse rounded mt-1"></div>
</div>
</div>
{/each}
</div>
{:else if recentTransactions.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Transaktionen vorhanden.</p>
<div class="mb-6 flex items-center justify-between">
<PageHeader
title={$_('dashboard.title')}
description="{$_('dashboard.welcome')}, {authStore.user?.email || 'User'}"
size="lg"
/>
<button
type="button"
onclick={() => dashboardStore.toggleEditing()}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {dashboardStore.isEditing
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
>
{#if dashboardStore.isEditing}
<span class="flex items-center gap-2">
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 13l4 4L19 7" />
</svg>
{$_('dashboard.done')}
</span>
{:else}
<div class="space-y-3">
{#each recentTransactions as tx}
<div
class="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<div class="flex items-center gap-3">
<span class="text-xl">{getTransactionIcon(tx.type)}</span>
<div>
<p class="font-medium text-sm">{tx.description || tx.type}</p>
<p class="text-xs text-muted-foreground">
{new Date(tx.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
</div>
<span
class="font-semibold {tx.amount > 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'}"
>
{tx.amount > 0 ? '+' : ''}{formatCredits(tx.amount)}
</span>
</div>
{/each}
</div>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
{$_('dashboard.customize')}
</span>
{/if}
</Card>
</button>
</div>
<DashboardGrid />
</div>