Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 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/auth.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

@ -3,7 +3,7 @@
* Handles credit balance, transactions, and packages
*/
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env

View file

@ -3,7 +3,7 @@
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env

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

View file

@ -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,14 @@
/**
* 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';
export { pictureService, type GeneratedImage, type GenerationStats } from './picture';
export { manadeckService, type Deck, type Card, type LearningProgress } from './manadeck';
export { clockService, type Timer, type Alarm, type ClockStats } from './clock';

View file

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

View file

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

View file

@ -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,144 @@
<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';
import PictureRecentWidget from './widgets/PictureRecentWidget.svelte';
import ManadeckProgressWidget from './widgets/ManadeckProgressWidget.svelte';
import ClockTimersWidget from './widgets/ClockTimersWidget.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,
'picture-recent': PictureRecentWidget,
'manadeck-progress': ManadeckProgressWidget,
'clock-timers': ClockTimersWidget,
} 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,184 @@
<script lang="ts">
/**
* ClockTimersWidget - Active timers and alarms
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { clockService, type Timer, type Alarm, type ClockStats } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let timers = $state<Timer[]>([]);
let alarms = $state<Alarm[]>([]);
let stats = $state<ClockStats | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
async function load() {
state = 'loading';
retrying = true;
const [timersResult, alarmsResult, statsResult] = await Promise.all([
clockService.getActiveTimers(),
clockService.getEnabledAlarms(),
clockService.getStats(),
]);
if (timersResult.data && alarmsResult.data && statsResult.data) {
timers = timersResult.data;
alarms = alarmsResult.data.slice(0, 3);
stats = statsResult.data;
state = 'success';
retryCount = 0;
} else {
error = timersResult.error || alarmsResult.error || statsResult.error;
state = 'error';
if (retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function formatTime(seconds: number): string {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatAlarmDays(days: number[]): string {
if (days.length === 7) return 'Täglich';
if (days.length === 5 && !days.includes(0) && !days.includes(6)) return 'Werktags';
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Wochenende';
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
return days.map((d) => dayNames[d]).join(', ');
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span></span>
{$_('dashboard.widgets.clock.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if timers.length === 0 && alarms.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">🕐</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.clock.empty')}
</p>
<a
href="http://localhost:5177"
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.clock.open')}
</a>
</div>
{:else}
<!-- Stats row (if pomodoros today) -->
{#if stats && stats.pomodorosToday > 0}
<div class="mb-3 flex items-center justify-center gap-4 rounded-lg bg-surface-hover p-2">
<div class="flex items-center gap-1">
<span>🍅</span>
<span class="font-medium">{stats.pomodorosToday}</span>
<span class="text-xs text-muted-foreground">Pomodoros</span>
</div>
<div class="flex items-center gap-1">
<span>⏱️</span>
<span class="font-medium">{stats.focusTimeToday}</span>
<span class="text-xs text-muted-foreground">min</span>
</div>
</div>
{/if}
<!-- Active timers -->
{#if timers.length > 0}
<div class="mb-3">
<div class="mb-2 text-xs font-medium uppercase text-muted-foreground">
{$_('dashboard.widgets.clock.active_timers')}
</div>
<div class="space-y-2">
{#each timers as timer}
<div
class="flex items-center justify-between rounded-lg p-2 {timer.isRunning
? 'bg-primary/10'
: 'bg-surface-hover'}"
>
<div class="flex items-center gap-2">
{#if timer.isRunning}
<span class="relative flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-primary"></span>
</span>
{:else}
<span class="h-2 w-2 rounded-full bg-yellow-500"></span>
{/if}
<span class="text-sm font-medium">{timer.name || 'Timer'}</span>
</div>
<span class="font-mono text-sm font-bold {timer.isRunning ? 'text-primary' : ''}">
{formatTime(timer.remaining)}
</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Alarms -->
{#if alarms.length > 0}
<div>
<div class="mb-2 text-xs font-medium uppercase text-muted-foreground">
{$_('dashboard.widgets.clock.alarms')}
</div>
<div class="space-y-2">
{#each alarms as alarm}
<div class="flex items-center justify-between rounded-lg bg-surface-hover p-2">
<div>
<div class="font-mono text-lg font-bold">{alarm.time}</div>
<div class="text-xs text-muted-foreground">
{alarm.name || formatAlarmDays(alarm.days)}
</div>
</div>
<div class="text-lg">🔔</div>
</div>
{/each}
</div>
</div>
{/if}
<div class="mt-3 text-center">
<a
href="http://localhost:5177"
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.clock.open')}
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,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,155 @@
<script lang="ts">
/**
* ManadeckProgressWidget - Learning progress and due cards
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { manadeckService, type LearningProgress, type Deck } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let progress = $state<LearningProgress | null>(null);
let decks = $state<Deck[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
async function load() {
state = 'loading';
retrying = true;
const [progressResult, decksResult] = await Promise.all([
manadeckService.getLearningProgress(),
manadeckService.getDecks(),
]);
if (progressResult.data && decksResult.data) {
progress = progressResult.data;
decks = decksResult.data;
state = 'success';
retryCount = 0;
} else {
error = progressResult.error || decksResult.error;
state = 'error';
if (retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
// Calculate progress percentage
const progressPercent = $derived(
progress && progress.totalCards > 0
? Math.round((progress.cardsLearned / progress.totalCards) * 100)
: 0
);
// Get decks with due cards
const decksWithDue = $derived(decks.filter((d) => d.dueCount > 0).slice(0, 3));
// Total due cards
const totalDue = $derived(decks.reduce((sum, d) => sum + d.dueCount, 0));
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🎴</span>
{$_('dashboard.widgets.manadeck.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if !progress || decks.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">📚</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.manadeck.empty')}
</p>
<a
href="http://localhost:5176"
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.manadeck.create_deck')}
</a>
</div>
{:else}
<!-- Stats row -->
<div class="mb-4 grid grid-cols-3 gap-2 text-center">
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-primary">{progress.streakDays}</div>
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.streak')}</div>
</div>
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-orange-500">{totalDue}</div>
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.due')}</div>
</div>
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-green-500">{progress.reviewsToday}</div>
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.today')}</div>
</div>
</div>
<!-- Progress bar -->
<div class="mb-4">
<div class="mb-1 flex justify-between text-xs text-muted-foreground">
<span>{$_('dashboard.widgets.manadeck.learned')}</span>
<span>{progressPercent}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-surface-hover">
<div
class="h-full rounded-full bg-primary transition-all duration-500"
style="width: {progressPercent}%"
></div>
</div>
</div>
<!-- Decks with due cards -->
{#if decksWithDue.length > 0}
<div class="space-y-2">
{#each decksWithDue as deck}
<a
href="http://localhost:5176/deck/{deck.id}/study"
target="_blank"
rel="noopener"
class="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<span class="truncate text-sm font-medium">{deck.name}</span>
<span class="flex items-center gap-1 text-sm text-orange-500">
{deck.dueCount}
<span class="text-xs text-muted-foreground"
>{$_('dashboard.widgets.manadeck.due')}</span
>
</span>
</a>
{/each}
</div>
{/if}
{#if totalDue > 0}
<div class="mt-3 text-center">
<a
href="http://localhost:5176/study"
target="_blank"
rel="noopener"
class="inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$_('dashboard.widgets.manadeck.start_study')}
</a>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,130 @@
<script lang="ts">
/**
* PictureRecentWidget - Recent AI-generated images
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { pictureService, type GeneratedImage } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<GeneratedImage[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const MAX_DISPLAY = 6;
async function load() {
state = 'loading';
retrying = true;
const result = await pictureService.getRecentGenerations(MAX_DISPLAY);
if (result.data) {
data = result.data;
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
if (retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
if (date.toDateString() === yesterday.toDateString()) {
return 'Gestern';
}
return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
function truncatePrompt(prompt: string, maxLength = 40): string {
if (prompt.length <= maxLength) return prompt;
return prompt.slice(0, maxLength) + '...';
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🎨</span>
{$_('dashboard.widgets.picture.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">🖼️</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.picture.empty')}
</p>
<a
href="http://localhost:5175"
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.picture.create')}
</a>
</div>
{:else}
<div class="grid grid-cols-3 gap-2">
{#each data as image}
<a
href="http://localhost:5175/gallery/{image.id}"
target="_blank"
rel="noopener"
class="group relative aspect-square overflow-hidden rounded-lg bg-surface-hover"
>
<img
src={image.thumbnailUrl || image.imageUrl}
alt={truncatePrompt(image.prompt)}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
<div
class="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100"
>
<p class="p-2 text-xs text-white">{truncatePrompt(image.prompt, 30)}</p>
</div>
{#if image.isFavorite}
<div class="absolute right-1 top-1 text-sm">❤️</div>
{/if}
</a>
{/each}
</div>
<div class="mt-3 text-center">
<a
href="http://localhost:5175/gallery"
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.picture.view_all')}
</a>
</div>
{/if}
</div>

View file

@ -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

@ -1,6 +1,6 @@
/**
* Multi-app configuration for welcome page branding
* Supports multiple apps using the same authentication system
* Multi-app configuration for Manacore dashboard and welcome page branding
* Defines all apps in the Mana ecosystem with their features and styling
*/
export interface AppFeature {
@ -24,140 +24,17 @@ export interface AppConfig {
}
/**
* App configurations for different applications in the ecosystem
* App configurations for all applications in the Mana ecosystem
*/
export const appConfigs: Record<string, AppConfig> = {
memoro: {
name: 'memoro',
displayName: 'Memoro',
tagline: 'Your Voice, Your Memories, AI-Powered',
description:
'Transform your voice recordings into searchable, organized memories with the power of AI.',
logoEmoji: '🎙️',
primaryColor: '#3B82F6',
accentColor: '#60A5FA',
features: [
{
icon: '🎤',
title: 'Voice Recording',
description: 'Capture your thoughts instantly with high-quality audio recording',
color: '#FF6B6B',
},
{
icon: '✨',
title: 'AI Transcription',
description: 'Automatic transcription and smart summarization of your recordings',
color: '#4ECDC4',
},
{
icon: '🔍',
title: 'Smart Search',
description: 'Find any memory instantly with powerful search across all your memos',
color: '#45B7D1',
},
{
icon: '🏷️',
title: 'Organization',
description: 'Tag, categorize, and organize your memories effortlessly',
color: '#FFEAA7',
},
{
icon: '🌐',
title: 'Web & Mobile',
description: 'Access your memories anywhere - web, iOS, and Android',
color: '#74B9FF',
},
{
icon: '👥',
title: 'Collaboration',
description: 'Share and collaborate on memories with spaces and teams',
color: '#DDA0DD',
},
],
dashboardRoute: '/dashboard',
website: 'https://memoro.app',
},
manadeck: {
name: 'manadeck',
displayName: 'ManaDeck',
tagline: 'Master Any Subject with AI-Powered Flashcards',
description: 'Create, study, and master flashcard decks with intelligent spaced repetition.',
logoEmoji: '🎴',
primaryColor: '#8B5CF6',
accentColor: '#A78BFA',
features: [
{
icon: '🎴',
title: 'Smart Flashcards',
description: 'AI-generated flashcards from your notes and content',
color: '#8B5CF6',
},
{
icon: '🧠',
title: 'Spaced Repetition',
description: 'Intelligent review scheduling for optimal retention',
color: '#EC4899',
},
{
icon: '📊',
title: 'Progress Tracking',
description: 'Detailed analytics and insights on your learning journey',
color: '#10B981',
},
{
icon: '🌍',
title: 'Multi-Language',
description: 'Study in any language with full internationalization',
color: '#F59E0B',
},
],
dashboardRoute: '/dashboard',
},
storyteller: {
name: 'storyteller',
displayName: 'Storyteller',
tagline: 'Craft Beautiful Stories with AI',
description:
'Create, edit, and publish captivating stories with AI-powered writing assistance.',
logoEmoji: '📖',
primaryColor: '#F59E0B',
accentColor: '#FBBF24',
features: [
{
icon: '✍️',
title: 'AI Writing Assistant',
description: "Get intelligent suggestions and overcome writer's block",
color: '#F59E0B',
},
{
icon: '📚',
title: 'Story Organization',
description: 'Manage chapters, characters, and plotlines effortlessly',
color: '#8B5CF6',
},
{
icon: '🎨',
title: 'Beautiful Formatting',
description: 'Professional typography and formatting tools',
color: '#EC4899',
},
{
icon: '🚀',
title: 'One-Click Publishing',
description: 'Publish your stories directly to multiple platforms',
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
},
// ============================================
// CORE PLATFORM
// ============================================
manacore: {
name: 'manacore',
displayName: 'ManaCore',
tagline: 'Your Universal Account',
description: 'One account for all your Mana-powered applications.',
tagline: 'Dein Universal-Account',
description: 'Ein Account für alle Mana-Anwendungen.',
logoEmoji: '⚡',
primaryColor: '#6366F1',
accentColor: '#818CF8',
@ -165,81 +42,588 @@ export const appConfigs: Record<string, AppConfig> = {
{
icon: '🔐',
title: 'Single Sign-On',
description: 'One account across all Mana applications',
description: 'Ein Account für alle Mana-Apps',
color: '#6366F1',
},
{
icon: '👤',
title: 'Unified Profile',
description: 'Manage your profile and preferences in one place',
title: 'Einheitliches Profil',
description: 'Verwalte dein Profil an einem Ort',
color: '#8B5CF6',
},
{
icon: '🏢',
title: 'Organization Management',
description: 'Create and manage teams across all apps',
title: 'Organisation & Teams',
description: 'Erstelle und verwalte Teams über alle Apps',
color: '#EC4899',
},
{
icon: '💳',
title: 'Mana Credits',
description: 'Universal credit system for all Mana services',
description: 'Universelles Credit-System für alle Mana-Services',
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
},
// ============================================
// AI-POWERED APPS
// ============================================
chat: {
name: 'chat',
displayName: 'ManaChat',
tagline: 'KI Chat Assistent',
description: 'Dein intelligenter KI-Begleiter für Gespräche, Fragen und kreative Aufgaben.',
logoEmoji: '💬',
primaryColor: '#0EA5E9',
accentColor: '#38BDF8',
features: [
{
icon: '🤖',
title: 'Mehrere KI-Modelle',
description: 'Wähle zwischen verschiedenen AI-Modellen',
color: '#0EA5E9',
},
{
icon: '💬',
title: 'Konversationsverlauf',
description: 'Alle Chats gespeichert und durchsuchbar',
color: '#06B6D4',
},
{
icon: '📝',
title: 'Kreatives Schreiben',
description: 'Texte, Code und mehr generieren',
color: '#8B5CF6',
},
{
icon: '🌐',
title: 'Mehrsprachig',
description: 'Unterstützung für viele Sprachen',
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
website: 'https://chat.manacore.app',
},
picture: {
name: 'picture',
displayName: 'ManaPicture',
tagline: 'KI Bildgenerierung',
description: 'Erschaffe einzigartige Bilder mit der Kraft künstlicher Intelligenz.',
logoEmoji: '🎨',
primaryColor: '#22C55E',
accentColor: '#4ADE80',
features: [
{
icon: '🎨',
title: 'KI-Bildgenerierung',
description: 'Erstelle Bilder aus Textbeschreibungen',
color: '#22C55E',
},
{
icon: '🖼️',
title: 'Galerie',
description: 'Alle generierten Bilder organisiert',
color: '#10B981',
},
{
icon: '✨',
title: 'Verschiedene Stile',
description: 'Fotorealistisch, Cartoon, Kunst und mehr',
color: '#8B5CF6',
},
{
icon: '📐',
title: 'Flexible Formate',
description: 'Verschiedene Größen und Seitenverhältnisse',
color: '#F59E0B',
},
],
dashboardRoute: '/dashboard',
website: 'https://picture.manacore.app',
},
presi: {
name: 'presi',
displayName: 'Presi',
tagline: 'Präsentations-Creator',
description: 'Erstelle beeindruckende Präsentationen mit KI-gestützten Design-Vorschlägen.',
logoEmoji: '📊',
primaryColor: '#F97316',
accentColor: '#FB923C',
features: [
{
icon: '📊',
title: 'Slide-Editor',
description: 'Intuitive Folien-Bearbeitung',
color: '#F97316',
},
{
icon: '🎨',
title: 'Design-Vorlagen',
description: 'Professionelle Templates',
color: '#EC4899',
},
{
icon: '✨',
title: 'KI-Unterstützung',
description: 'Automatische Design-Vorschläge',
color: '#8B5CF6',
},
{
icon: '📤',
title: 'Export',
description: 'PDF, PPTX und mehr',
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
website: 'https://presi.manacore.app',
},
mail: {
name: 'mail',
displayName: 'ManaMail',
tagline: 'Smart Email Client',
description:
'Intelligenter E-Mail-Client mit KI-Zusammenfassungen, Smart Reply und Multi-Account.',
logoEmoji: '📧',
primaryColor: '#6366F1',
accentColor: '#818CF8',
features: [
{
icon: '📧',
title: 'Multi-Account',
description: 'Alle E-Mail-Konten an einem Ort',
color: '#6366F1',
},
{
icon: '🤖',
title: 'KI-Zusammenfassungen',
description: 'Lange E-Mails auf den Punkt gebracht',
color: '#8B5CF6',
},
{
icon: '⚡',
title: 'Smart Reply',
description: 'KI-generierte Antwortvorschläge',
color: '#10B981',
},
{
icon: '🔍',
title: 'Intelligente Suche',
description: 'Finde jede E-Mail sofort',
color: '#0EA5E9',
},
],
dashboardRoute: '/dashboard',
website: 'https://mail.manacore.app',
},
// ============================================
// PRODUCTIVITY APPS
// ============================================
manadeck: {
name: 'manadeck',
displayName: 'ManaDeck',
tagline: 'KI Karteikarten',
description: 'Lerne intelligenter mit KI-generierten Karteikarten und Spaced Repetition.',
logoEmoji: '🎴',
primaryColor: '#8B5CF6',
accentColor: '#A78BFA',
features: [
{
icon: '🎴',
title: 'Smarte Karteikarten',
description: 'KI-generierte Lernkarten aus deinen Notizen',
color: '#8B5CF6',
},
{
icon: '🧠',
title: 'Spaced Repetition',
description: 'Intelligente Wiederholung für optimales Lernen',
color: '#EC4899',
},
{
icon: '📊',
title: 'Lernfortschritt',
description: 'Detaillierte Statistiken und Insights',
color: '#10B981',
},
{
icon: '🌍',
title: 'Mehrsprachig',
description: 'Lerne in jeder Sprache',
color: '#F59E0B',
},
],
dashboardRoute: '/dashboard',
website: 'https://manadeck.manacore.app',
},
todo: {
name: 'todo',
displayName: 'ManaTodo',
tagline: 'Aufgabenverwaltung',
description: 'Verwalte Aufgaben mit Projekten, Labels, Subtasks und wiederkehrenden Terminen.',
logoEmoji: '✅',
primaryColor: '#8B5CF6',
accentColor: '#A78BFA',
features: [
{
icon: '✅',
title: 'Aufgaben',
description: 'Erstelle und verwalte Aufgaben',
color: '#8B5CF6',
},
{
icon: '📁',
title: 'Projekte',
description: 'Organisiere Aufgaben in Projekten',
color: '#10B981',
},
{
icon: '🏷️',
title: 'Labels & Tags',
description: 'Flexible Kategorisierung',
color: '#F59E0B',
},
{
icon: '🔄',
title: 'Wiederkehrend',
description: 'Automatisch wiederholende Aufgaben',
color: '#EC4899',
},
],
dashboardRoute: '/dashboard',
website: 'https://todo.manacore.app',
},
calendar: {
name: 'calendar',
displayName: 'ManaCalendar',
tagline: 'Smarte Kalenderverwaltung',
description:
'Organisiere deine Zeit mit persönlichen und geteilten Kalendern, wiederkehrenden Terminen und Erinnerungen.',
logoEmoji: '📅',
primaryColor: '#0EA5E9',
accentColor: '#38BDF8',
features: [
{
icon: '📅',
title: 'Kalender',
description: 'Persönliche und geteilte Kalender',
color: '#0EA5E9',
},
{
icon: '🔄',
title: 'Wiederkehrende Termine',
description: 'Flexible Wiederholungsmuster',
color: '#10B981',
},
{
icon: '🔔',
title: 'Erinnerungen',
description: 'Verpasse keinen Termin',
color: '#F59E0B',
},
{
icon: '🔗',
title: 'CalDAV/iCal Sync',
description: 'Synchronisiere mit anderen Apps',
color: '#8B5CF6',
},
],
dashboardRoute: '/dashboard',
website: 'https://calendar.manacore.app',
},
contacts: {
name: 'contacts',
displayName: 'ManaContacts',
tagline: 'Kontaktverwaltung',
description: 'Verwalte deine Kontakte übersichtlich mit Gruppen, Tags und Notizen.',
logoEmoji: '👥',
primaryColor: '#3B82F6',
accentColor: '#60A5FA',
features: [
{
icon: '👥',
title: 'Kontakte',
description: 'Alle Kontakte an einem Ort',
color: '#3B82F6',
},
{
icon: '🏷️',
title: 'Gruppen & Tags',
description: 'Flexible Organisation',
color: '#10B981',
},
{
icon: '📥',
title: 'Import/Export',
description: 'VCF, CSV und mehr',
color: '#F59E0B',
},
{
icon: '🔄',
title: 'Google Sync',
description: 'Synchronisiere mit Google Contacts',
color: '#EC4899',
},
],
dashboardRoute: '/dashboard',
website: 'https://contacts.manacore.app',
},
finance: {
name: 'finance',
displayName: 'ManaFinance',
tagline: 'Budget-Tracker & Finanzübersicht',
description:
'Behalte deine Finanzen im Blick mit Multi-Currency-Konten, Transaktionen und Budgets.',
logoEmoji: '💰',
primaryColor: '#10B981',
accentColor: '#34D399',
features: [
{
icon: '💰',
title: 'Konten',
description: 'Verwalte mehrere Konten',
color: '#10B981',
},
{
icon: '💳',
title: 'Transaktionen',
description: 'Erfasse Ein- und Ausgaben',
color: '#3B82F6',
},
{
icon: '📊',
title: 'Budgets',
description: 'Setze Limits und behalte den Überblick',
color: '#F59E0B',
},
{
icon: '🌍',
title: 'Multi-Currency',
description: 'Unterstützung für alle Währungen',
color: '#8B5CF6',
},
],
dashboardRoute: '/dashboard',
website: 'https://finance.manacore.app',
},
// ============================================
// UTILITY APPS
// ============================================
clock: {
name: 'clock',
displayName: 'ManaClock',
tagline: 'Uhren & Timer',
description: 'Weltzeituhr, Wecker, Timer und stilvolle Uhren-Widgets in einer App.',
logoEmoji: '⏰',
primaryColor: '#F59E0B',
accentColor: '#FBBF24',
features: [
{
icon: '🌍',
title: 'Weltzeituhr',
description: 'Zeitzonen weltweit im Blick',
color: '#0EA5E9',
},
{
icon: '⏰',
title: 'Wecker',
description: 'Flexible Alarm-Einstellungen',
color: '#F59E0B',
},
{
icon: '⏱️',
title: 'Timer & Stoppuhr',
description: 'Für alle Timing-Aufgaben',
color: '#10B981',
},
{
icon: '🍅',
title: 'Pomodoro',
description: 'Fokussiertes Arbeiten',
color: '#EF4444',
},
],
dashboardRoute: '/dashboard',
website: 'https://clock.manacore.app',
},
zitare: {
name: 'zitare',
displayName: 'Zitare',
tagline: 'Tägliche Inspiration',
description: 'Entdecke inspirierende Zitate und Weisheiten für jeden Tag.',
logoEmoji: '💡',
primaryColor: '#F59E0B',
accentColor: '#FBBF24',
features: [
{
icon: '💡',
title: 'Tägliche Zitate',
description: 'Jeden Tag neue Inspiration',
color: '#F59E0B',
},
{
icon: '❤️',
title: 'Favoriten',
description: 'Speichere deine Lieblingszitate',
color: '#EF4444',
},
{
icon: '🎯',
title: 'Personalisiert',
description: 'Zitate nach deinem Geschmack',
color: '#8B5CF6',
},
{
icon: '📤',
title: 'Teilen',
description: 'Teile Zitate mit Freunden',
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
website: 'https://zitare.manacore.app',
},
storage: {
name: 'storage',
displayName: 'ManaStorage',
tagline: 'Cloud-Speicherung',
description: 'Sichere Cloud-Speicherung für deine Dateien mit Ordnern, Sharing und mehr.',
logoEmoji: '☁️',
primaryColor: '#3B82F6',
accentColor: '#60A5FA',
features: [
{
icon: '☁️',
title: 'Cloud-Speicher',
description: 'Deine Dateien sicher in der Cloud',
color: '#3B82F6',
},
{
icon: '📁',
title: 'Ordner',
description: 'Organisiere deine Dateien',
color: '#10B981',
},
{
icon: '🔗',
title: 'Sharing',
description: 'Teile Dateien mit anderen',
color: '#8B5CF6',
},
{
icon: '📱',
title: 'Überall verfügbar',
description: 'Zugriff von jedem Gerät',
color: '#F59E0B',
},
],
dashboardRoute: '/dashboard',
website: 'https://storage.manacore.app',
},
moodlit: {
name: 'moodlit',
displayName: 'Moodlit',
tagline: 'Ambient Lighting & Moods',
description:
'Erstelle beruhigende Lichtstimmungen mit animierten Farbverläufen für entspannte Atmosphäre.',
logoEmoji: '🌈',
primaryColor: '#8B5CF6',
accentColor: '#A78BFA',
features: [
{
icon: '🌈',
title: 'Farbverläufe',
description: 'Animierte Ambient-Beleuchtung',
color: '#8B5CF6',
},
{
icon: '🎨',
title: 'Themes',
description: 'Vordefinierte Stimmungen',
color: '#EC4899',
},
{
icon: '✨',
title: 'Animationen',
description: 'Sanfte, beruhigende Bewegungen',
color: '#F59E0B',
},
{
icon: '🌙',
title: 'Nachtmodus',
description: 'Perfekt zum Einschlafen',
color: '#6366F1',
},
],
dashboardRoute: '/dashboard',
website: 'https://moodlit.manacore.app',
},
};
/**
* Default Mana branding for when no app is specified
* Default Mana branding for the platform overview
*/
export const defaultManaConfig: AppConfig = {
name: 'mana',
displayName: 'Mana',
tagline: 'The Unified Application Platform',
tagline: 'Die einheitliche App-Plattform',
description:
'Access all your Mana-powered applications with a single account. Built for productivity, powered by AI.',
'Greife auf alle Mana-Apps mit einem einzigen Account zu. Gebaut für Produktivität, angetrieben von KI.',
logoEmoji: '⚡',
primaryColor: '#6366F1',
accentColor: '#818CF8',
features: [
{
icon: '🎙️',
title: 'Memoro',
description: 'AI-powered voice memos and memory management',
color: '#3B82F6',
icon: '💬',
title: 'ManaChat',
description: 'KI-Chat mit verschiedenen Modellen',
color: '#0EA5E9',
},
{
icon: '🎨',
title: 'ManaPicture',
description: 'KI-Bildgenerierung',
color: '#22C55E',
},
{
icon: '🎴',
title: 'ManaDeck',
description: 'Intelligent flashcard learning platform',
description: 'Intelligente Lernkarten',
color: '#8B5CF6',
},
{
icon: '📖',
title: 'Storyteller',
description: 'Creative writing with AI assistance',
icon: '📅',
title: 'ManaCalendar',
description: 'Smarte Kalenderverwaltung',
color: '#0EA5E9',
},
{
icon: '✅',
title: 'ManaTodo',
description: 'Aufgabenverwaltung mit Projekten',
color: '#8B5CF6',
},
{
icon: '💡',
title: 'Zitare',
description: 'Tägliche Inspiration',
color: '#F59E0B',
},
{
icon: '⚡',
title: 'ManaCore',
description: 'Universal account and organization management',
color: '#6366F1',
},
{
icon: '🔐',
title: 'Single Sign-On',
description: 'One account for all Mana applications',
color: '#10B981',
},
{
icon: '🌍',
title: 'Cross-Platform',
description: 'Web, iOS, and Android support',
color: '#EC4899',
},
],
dashboardRoute: '/dashboard',
};
@ -256,3 +640,33 @@ export function getAppConfig(appName?: string | null): AppConfig {
const normalizedName = appName.toLowerCase().trim();
return appConfigs[normalizedName] || defaultManaConfig;
}
/**
* Get all active app configurations
*/
export function getAllAppConfigs(): AppConfig[] {
return Object.values(appConfigs);
}
/**
* Get app configurations by category
*/
export function getAppsByCategory(): {
core: AppConfig[];
ai: AppConfig[];
productivity: AppConfig[];
utility: AppConfig[];
} {
return {
core: [appConfigs.manacore],
ai: [appConfigs.chat, appConfigs.picture, appConfigs.presi, appConfigs.mail],
productivity: [
appConfigs.manadeck,
appConfigs.todo,
appConfigs.calendar,
appConfigs.contacts,
appConfigs.finance,
],
utility: [appConfigs.clock, appConfigs.zitare, appConfigs.storage, appConfigs.moodlit],
};
}

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,101 @@
"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"
},
"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"
}
}
},
"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,101 @@
"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"
},
"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"
}
}
},
"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

@ -8,7 +8,7 @@
*/
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './authStore.svelte';
import { authStore } from './auth.svelte';
// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available
const MANA_AUTH_URL = 'http://localhost:3001';

View file

@ -0,0 +1,247 @@
/**
* 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: 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
* - 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'
| 'picture'
| 'manadeck'
| 'clock'
| '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',
},
{
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',
},
];
/**
* 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

@ -10,7 +10,7 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import {
isSidebarMode as sidebarModeStore,
@ -125,8 +125,14 @@
$effect(() => {
// Redirect to login if not authenticated (after initialization)
if (authStore.initialized && !authStore.isAuthenticated) {
goto('/login');
// Use a small delay to ensure state has propagated after navigation
if (authStore.initialized && !authStore.loading && !authStore.isAuthenticated) {
// Small delay to handle navigation timing
setTimeout(() => {
if (!authStore.isAuthenticated) {
goto('/login');
}
}, 100);
}
});
@ -148,6 +154,16 @@
// Load user settings from server
if (authStore.isAuthenticated) {
await userSettings.load();
// Redirect to start page if on /dashboard and a custom start page is set
const currentPath = window.location.pathname;
if (
currentPath === '/dashboard' &&
userSettings.startPage &&
userSettings.startPage !== '/dashboard'
) {
goto(userSettings.startPage, { replaceState: true });
}
}
loading = false;

View file

@ -5,185 +5,55 @@
import type { CreditBalance, CreditTransaction } from '$lib/api/credits';
import { authStore } from '$lib/stores/authStore.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>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { feedbackService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<FeedbackPage {feedbackService} appName="ManaCore" currentUserId={authStore.user?.id} />

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
// Map auth store user to UserProfile

View file

@ -3,7 +3,7 @@
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);

View file

@ -6,7 +6,7 @@
import { getLoginTranslations } from '@manacore/shared-i18n';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));

View file

@ -3,7 +3,7 @@
import { RegisterPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);

View file

@ -2,7 +2,7 @@
import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/authStore.svelte';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();