mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 20:46:41 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
|
|
@ -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"
|
||||
|
|
|
|||
157
apps/manacore/apps/web/src/lib/api/base-client.ts
Normal file
157
apps/manacore/apps/web/src/lib/api/base-client.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
94
apps/manacore/apps/web/src/lib/api/services/calendar.ts
Normal file
94
apps/manacore/apps/web/src/lib/api/services/calendar.ts
Normal 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}`
|
||||
);
|
||||
},
|
||||
};
|
||||
110
apps/manacore/apps/web/src/lib/api/services/chat.ts
Normal file
110
apps/manacore/apps/web/src/lib/api/services/chat.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
199
apps/manacore/apps/web/src/lib/api/services/clock.ts
Normal file
199
apps/manacore/apps/web/src/lib/api/services/clock.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* Clock API Service
|
||||
*
|
||||
* Fetches timers and alarms from local storage for dashboard widgets.
|
||||
* Note: Clock app stores data in localStorage, not a backend.
|
||||
*/
|
||||
|
||||
import type { ApiResult } from '../base-client';
|
||||
|
||||
/**
|
||||
* Timer entity from Clock app
|
||||
*/
|
||||
export interface Timer {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number; // Total duration in seconds
|
||||
remaining: number; // Remaining time in seconds
|
||||
isRunning: boolean;
|
||||
isPaused: boolean;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm entity from Clock app
|
||||
*/
|
||||
export interface Alarm {
|
||||
id: string;
|
||||
name: string;
|
||||
time: string; // HH:MM format
|
||||
days: number[]; // 0-6 (Sunday to Saturday)
|
||||
isEnabled: boolean;
|
||||
sound?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pomodoro session from Clock app
|
||||
*/
|
||||
export interface PomodoroSession {
|
||||
id: string;
|
||||
type: 'work' | 'shortBreak' | 'longBreak';
|
||||
duration: number;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clock statistics
|
||||
*/
|
||||
export interface ClockStats {
|
||||
activeTimers: number;
|
||||
enabledAlarms: number;
|
||||
pomodorosToday: number;
|
||||
focusTimeToday: number; // In minutes
|
||||
}
|
||||
|
||||
// LocalStorage keys (matching Clock app's storage)
|
||||
const STORAGE_KEYS = {
|
||||
timers: 'clock-timers',
|
||||
alarms: 'clock-alarms',
|
||||
pomodoros: 'clock-pomodoros',
|
||||
};
|
||||
|
||||
/**
|
||||
* Clock service for dashboard widgets
|
||||
*
|
||||
* Since Clock stores data in localStorage, this service reads from there.
|
||||
*/
|
||||
export const clockService = {
|
||||
/**
|
||||
* Get all timers
|
||||
*/
|
||||
async getTimers(): Promise<ApiResult<Timer[]>> {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return { data: [], error: null };
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.timers);
|
||||
const timers = stored ? JSON.parse(stored) : [];
|
||||
return { data: timers, error: null };
|
||||
} catch {
|
||||
return { data: null, error: 'Failed to load timers' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get active timers (running or paused with time remaining)
|
||||
*/
|
||||
async getActiveTimers(): Promise<ApiResult<Timer[]>> {
|
||||
const result = await this.getTimers();
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const activeTimers = result.data.filter((t) => t.isRunning || (t.isPaused && t.remaining > 0));
|
||||
return { data: activeTimers, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all alarms
|
||||
*/
|
||||
async getAlarms(): Promise<ApiResult<Alarm[]>> {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return { data: [], error: null };
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.alarms);
|
||||
const alarms = stored ? JSON.parse(stored) : [];
|
||||
return { data: alarms, error: null };
|
||||
} catch {
|
||||
return { data: null, error: 'Failed to load alarms' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get enabled alarms sorted by next trigger time
|
||||
*/
|
||||
async getEnabledAlarms(): Promise<ApiResult<Alarm[]>> {
|
||||
const result = await this.getAlarms();
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const enabledAlarms = result.data
|
||||
.filter((a) => a.isEnabled)
|
||||
.sort((a, b) => {
|
||||
// Parse alarm times
|
||||
const [aHours, aMinutes] = a.time.split(':').map(Number);
|
||||
const [bHours, bMinutes] = b.time.split(':').map(Number);
|
||||
const aTime = aHours * 60 + aMinutes;
|
||||
const bTime = bHours * 60 + bMinutes;
|
||||
|
||||
// Sort by time of day
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
return { data: enabledAlarms, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get clock statistics
|
||||
*/
|
||||
async getStats(): Promise<ApiResult<ClockStats>> {
|
||||
try {
|
||||
const [timersResult, alarmsResult] = await Promise.all([
|
||||
this.getActiveTimers(),
|
||||
this.getEnabledAlarms(),
|
||||
]);
|
||||
|
||||
// Get pomodoro sessions for today
|
||||
const pomodorosStored = localStorage.getItem(STORAGE_KEYS.pomodoros);
|
||||
const pomodoros: PomodoroSession[] = pomodorosStored ? JSON.parse(pomodorosStored) : [];
|
||||
|
||||
const today = new Date().toDateString();
|
||||
const todayPomodoros = pomodoros.filter(
|
||||
(p) => new Date(p.completedAt).toDateString() === today
|
||||
);
|
||||
|
||||
const focusTimeToday = todayPomodoros
|
||||
.filter((p) => p.type === 'work')
|
||||
.reduce((sum, p) => sum + p.duration, 0);
|
||||
|
||||
return {
|
||||
data: {
|
||||
activeTimers: timersResult.data?.length || 0,
|
||||
enabledAlarms: alarmsResult.data?.length || 0,
|
||||
pomodorosToday: todayPomodoros.filter((p) => p.type === 'work').length,
|
||||
focusTimeToday: Math.round(focusTimeToday / 60),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch {
|
||||
return { data: null, error: 'Failed to load clock stats' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get next alarm time as a formatted string
|
||||
*/
|
||||
async getNextAlarmTime(): Promise<ApiResult<string | null>> {
|
||||
const result = await this.getEnabledAlarms();
|
||||
|
||||
if (result.error || !result.data || result.data.length === 0) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
|
||||
// Get next alarm
|
||||
const nextAlarm = result.data[0];
|
||||
return { data: nextAlarm.time, error: null };
|
||||
},
|
||||
};
|
||||
139
apps/manacore/apps/web/src/lib/api/services/contacts.ts
Normal file
139
apps/manacore/apps/web/src/lib/api/services/contacts.ts
Normal 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';
|
||||
},
|
||||
};
|
||||
14
apps/manacore/apps/web/src/lib/api/services/index.ts
Normal file
14
apps/manacore/apps/web/src/lib/api/services/index.ts
Normal 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';
|
||||
110
apps/manacore/apps/web/src/lib/api/services/manadeck.ts
Normal file
110
apps/manacore/apps/web/src/lib/api/services/manadeck.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* ManaDeck API Service
|
||||
*
|
||||
* Fetches learning progress and deck data from the ManaDeck backend for dashboard widgets.
|
||||
*/
|
||||
|
||||
import { createApiClient, type ApiResult } from '../base-client';
|
||||
|
||||
// Backend URL - falls back to localhost for development
|
||||
const MANADECK_API_URL = import.meta.env.PUBLIC_MANADECK_API_URL || 'http://localhost:3009';
|
||||
|
||||
const client = createApiClient(MANADECK_API_URL);
|
||||
|
||||
/**
|
||||
* Deck entity from ManaDeck backend
|
||||
*/
|
||||
export interface Deck {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cardCount: number;
|
||||
dueCount: number;
|
||||
newCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastStudied?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card entity from ManaDeck backend
|
||||
*/
|
||||
export interface Card {
|
||||
id: string;
|
||||
deckId: string;
|
||||
front: string;
|
||||
back: string;
|
||||
nextReview: string;
|
||||
interval: number;
|
||||
easeFactor: number;
|
||||
repetitions: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Learning progress statistics
|
||||
*/
|
||||
export interface LearningProgress {
|
||||
totalCards: number;
|
||||
cardsLearned: number;
|
||||
cardsDueToday: number;
|
||||
newCardsToday: number;
|
||||
streakDays: number;
|
||||
reviewsToday: number;
|
||||
averageRetention: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ManaDeck service for dashboard widgets
|
||||
*/
|
||||
export const manadeckService = {
|
||||
/**
|
||||
* Get user's decks
|
||||
*/
|
||||
async getDecks(): Promise<ApiResult<Deck[]>> {
|
||||
return client.get<Deck[]>('/api/decks');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get learning progress
|
||||
*/
|
||||
async getLearningProgress(): Promise<ApiResult<LearningProgress>> {
|
||||
return client.get<LearningProgress>('/api/progress');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get cards due for review today
|
||||
*/
|
||||
async getDueCards(limit = 10): Promise<ApiResult<Card[]>> {
|
||||
return client.get<Card[]>(`/api/cards/due?limit=${limit}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total due cards count across all decks
|
||||
*/
|
||||
async getTotalDueCount(): Promise<ApiResult<number>> {
|
||||
const result = await this.getDecks();
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
|
||||
const totalDue = result.data.reduce((sum, deck) => sum + deck.dueCount, 0);
|
||||
return { data: totalDue, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get study streak
|
||||
*/
|
||||
async getStreak(): Promise<ApiResult<number>> {
|
||||
const result = await this.getLearningProgress();
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
|
||||
return { data: result.data.streakDays, error: null };
|
||||
},
|
||||
};
|
||||
81
apps/manacore/apps/web/src/lib/api/services/picture.ts
Normal file
81
apps/manacore/apps/web/src/lib/api/services/picture.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Picture API Service
|
||||
*
|
||||
* Fetches recent AI-generated images from the Picture backend for dashboard widgets.
|
||||
*/
|
||||
|
||||
import { createApiClient, type ApiResult } from '../base-client';
|
||||
|
||||
// Backend URL - falls back to localhost for development
|
||||
const PICTURE_API_URL = import.meta.env.PUBLIC_PICTURE_API_URL || 'http://localhost:3006';
|
||||
|
||||
const client = createApiClient(PICTURE_API_URL);
|
||||
|
||||
/**
|
||||
* Generated image entity from Picture backend
|
||||
*/
|
||||
export interface GeneratedImage {
|
||||
id: string;
|
||||
userId: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
imageUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
model: string;
|
||||
seed?: number;
|
||||
steps?: number;
|
||||
cfgScale?: number;
|
||||
createdAt: string;
|
||||
isFavorite?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generation statistics
|
||||
*/
|
||||
export interface GenerationStats {
|
||||
totalGenerations: number;
|
||||
thisMonth: number;
|
||||
favoriteCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picture service for dashboard widgets
|
||||
*/
|
||||
export const pictureService = {
|
||||
/**
|
||||
* Get user's recent generations
|
||||
*/
|
||||
async getRecentGenerations(limit = 6): Promise<ApiResult<GeneratedImage[]>> {
|
||||
return client.get<GeneratedImage[]>(`/api/generations?limit=${limit}&sort=createdAt:desc`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user's favorite images
|
||||
*/
|
||||
async getFavorites(limit = 6): Promise<ApiResult<GeneratedImage[]>> {
|
||||
return client.get<GeneratedImage[]>(`/api/generations?favorite=true&limit=${limit}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get generation statistics
|
||||
*/
|
||||
async getStats(): Promise<ApiResult<GenerationStats>> {
|
||||
return client.get<GenerationStats>('/api/stats');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total generation count
|
||||
*/
|
||||
async getGenerationCount(): Promise<ApiResult<number>> {
|
||||
const result = await this.getStats();
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
|
||||
return { data: result.data.totalGenerations, error: null };
|
||||
},
|
||||
};
|
||||
95
apps/manacore/apps/web/src/lib/api/services/todo.ts
Normal file
95
apps/manacore/apps/web/src/lib/api/services/todo.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
92
apps/manacore/apps/web/src/lib/api/services/zitare.ts
Normal file
92
apps/manacore/apps/web/src/lib/api/services/zitare.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ClockTimersWidget - Active timers and alarms
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { clockService, type Timer, type Alarm, type ClockStats } from '$lib/api/services';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
|
||||
let state = $state<'loading' | 'success' | 'error'>('loading');
|
||||
let timers = $state<Timer[]>([]);
|
||||
let alarms = $state<Alarm[]>([]);
|
||||
let stats = $state<ClockStats | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let retrying = $state(false);
|
||||
let retryCount = $state(0);
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const [timersResult, alarmsResult, statsResult] = await Promise.all([
|
||||
clockService.getActiveTimers(),
|
||||
clockService.getEnabledAlarms(),
|
||||
clockService.getStats(),
|
||||
]);
|
||||
|
||||
if (timersResult.data && alarmsResult.data && statsResult.data) {
|
||||
timers = timersResult.data;
|
||||
alarms = alarmsResult.data.slice(0, 3);
|
||||
stats = statsResult.data;
|
||||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
error = timersResult.error || alarmsResult.error || statsResult.error;
|
||||
state = 'error';
|
||||
|
||||
if (retryCount < 3) {
|
||||
retryCount++;
|
||||
setTimeout(load, 5000 * retryCount);
|
||||
}
|
||||
}
|
||||
|
||||
retrying = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hrs > 0) {
|
||||
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatAlarmDays(days: number[]): string {
|
||||
if (days.length === 7) return 'Täglich';
|
||||
if (days.length === 5 && !days.includes(0) && !days.includes(6)) return 'Werktags';
|
||||
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Wochenende';
|
||||
|
||||
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
return days.map((d) => dayNames[d]).join(', ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span>⏰</span>
|
||||
{$_('dashboard.widgets.clock.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<WidgetSkeleton lines={3} />
|
||||
{:else if state === 'error'}
|
||||
<WidgetError {error} onRetry={load} {retrying} />
|
||||
{:else if timers.length === 0 && alarms.length === 0}
|
||||
<div class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">🕐</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('dashboard.widgets.clock.empty')}
|
||||
</p>
|
||||
<a
|
||||
href="http://localhost:5177"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
{$_('dashboard.widgets.clock.open')}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stats row (if pomodoros today) -->
|
||||
{#if stats && stats.pomodorosToday > 0}
|
||||
<div class="mb-3 flex items-center justify-center gap-4 rounded-lg bg-surface-hover p-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>🍅</span>
|
||||
<span class="font-medium">{stats.pomodorosToday}</span>
|
||||
<span class="text-xs text-muted-foreground">Pomodoros</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>⏱️</span>
|
||||
<span class="font-medium">{stats.focusTimeToday}</span>
|
||||
<span class="text-xs text-muted-foreground">min</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Active timers -->
|
||||
{#if timers.length > 0}
|
||||
<div class="mb-3">
|
||||
<div class="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
{$_('dashboard.widgets.clock.active_timers')}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each timers as timer}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg p-2 {timer.isRunning
|
||||
? 'bg-primary/10'
|
||||
: 'bg-surface-hover'}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if timer.isRunning}
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-primary"></span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="h-2 w-2 rounded-full bg-yellow-500"></span>
|
||||
{/if}
|
||||
<span class="text-sm font-medium">{timer.name || 'Timer'}</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-bold {timer.isRunning ? 'text-primary' : ''}">
|
||||
{formatTime(timer.remaining)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Alarms -->
|
||||
{#if alarms.length > 0}
|
||||
<div>
|
||||
<div class="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
{$_('dashboard.widgets.clock.alarms')}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each alarms as alarm}
|
||||
<div class="flex items-center justify-between rounded-lg bg-surface-hover p-2">
|
||||
<div>
|
||||
<div class="font-mono text-lg font-bold">{alarm.time}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{alarm.name || formatAlarmDays(alarm.days)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg">🔔</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 text-center">
|
||||
<a
|
||||
href="http://localhost:5177"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm text-primary hover:underline"
|
||||
>
|
||||
{$_('dashboard.widgets.clock.open')}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ManadeckProgressWidget - Learning progress and due cards
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { manadeckService, type LearningProgress, type Deck } from '$lib/api/services';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
|
||||
let state = $state<'loading' | 'success' | 'error'>('loading');
|
||||
let progress = $state<LearningProgress | null>(null);
|
||||
let decks = $state<Deck[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let retrying = $state(false);
|
||||
let retryCount = $state(0);
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const [progressResult, decksResult] = await Promise.all([
|
||||
manadeckService.getLearningProgress(),
|
||||
manadeckService.getDecks(),
|
||||
]);
|
||||
|
||||
if (progressResult.data && decksResult.data) {
|
||||
progress = progressResult.data;
|
||||
decks = decksResult.data;
|
||||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
error = progressResult.error || decksResult.error;
|
||||
state = 'error';
|
||||
|
||||
if (retryCount < 3) {
|
||||
retryCount++;
|
||||
setTimeout(load, 5000 * retryCount);
|
||||
}
|
||||
}
|
||||
|
||||
retrying = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercent = $derived(
|
||||
progress && progress.totalCards > 0
|
||||
? Math.round((progress.cardsLearned / progress.totalCards) * 100)
|
||||
: 0
|
||||
);
|
||||
|
||||
// Get decks with due cards
|
||||
const decksWithDue = $derived(decks.filter((d) => d.dueCount > 0).slice(0, 3));
|
||||
|
||||
// Total due cards
|
||||
const totalDue = $derived(decks.reduce((sum, d) => sum + d.dueCount, 0));
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span>🎴</span>
|
||||
{$_('dashboard.widgets.manadeck.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<WidgetSkeleton lines={4} />
|
||||
{:else if state === 'error'}
|
||||
<WidgetError {error} onRetry={load} {retrying} />
|
||||
{:else if !progress || decks.length === 0}
|
||||
<div class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">📚</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('dashboard.widgets.manadeck.empty')}
|
||||
</p>
|
||||
<a
|
||||
href="http://localhost:5176"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
{$_('dashboard.widgets.manadeck.create_deck')}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stats row -->
|
||||
<div class="mb-4 grid grid-cols-3 gap-2 text-center">
|
||||
<div class="rounded-lg bg-surface-hover p-2">
|
||||
<div class="text-xl font-bold text-primary">{progress.streakDays}</div>
|
||||
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.streak')}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover p-2">
|
||||
<div class="text-xl font-bold text-orange-500">{totalDue}</div>
|
||||
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.due')}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover p-2">
|
||||
<div class="text-xl font-bold text-green-500">{progress.reviewsToday}</div>
|
||||
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.manadeck.today')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{$_('dashboard.widgets.manadeck.learned')}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-surface-hover">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style="width: {progressPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decks with due cards -->
|
||||
{#if decksWithDue.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each decksWithDue as deck}
|
||||
<a
|
||||
href="http://localhost:5176/deck/{deck.id}/study"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span class="truncate text-sm font-medium">{deck.name}</span>
|
||||
<span class="flex items-center gap-1 text-sm text-orange-500">
|
||||
{deck.dueCount}
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>{$_('dashboard.widgets.manadeck.due')}</span
|
||||
>
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if totalDue > 0}
|
||||
<div class="mt-3 text-center">
|
||||
<a
|
||||
href="http://localhost:5176/study"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{$_('dashboard.widgets.manadeck.start_study')}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* PictureRecentWidget - Recent AI-generated images
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { pictureService, type GeneratedImage } from '$lib/api/services';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
|
||||
let state = $state<'loading' | 'success' | 'error'>('loading');
|
||||
let data = $state<GeneratedImage[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let retrying = $state(false);
|
||||
let retryCount = $state(0);
|
||||
|
||||
const MAX_DISPLAY = 6;
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const result = await pictureService.getRecentGenerations(MAX_DISPLAY);
|
||||
|
||||
if (result.data) {
|
||||
data = result.data;
|
||||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
|
||||
if (retryCount < 3) {
|
||||
retryCount++;
|
||||
setTimeout(load, 5000 * retryCount);
|
||||
}
|
||||
}
|
||||
|
||||
retrying = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Gestern';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
function truncatePrompt(prompt: string, maxLength = 40): string {
|
||||
if (prompt.length <= maxLength) return prompt;
|
||||
return prompt.slice(0, maxLength) + '...';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span>🎨</span>
|
||||
{$_('dashboard.widgets.picture.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<WidgetSkeleton lines={3} />
|
||||
{:else if state === 'error'}
|
||||
<WidgetError {error} onRetry={load} {retrying} />
|
||||
{:else if data.length === 0}
|
||||
<div class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">🖼️</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('dashboard.widgets.picture.empty')}
|
||||
</p>
|
||||
<a
|
||||
href="http://localhost:5175"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
{$_('dashboard.widgets.picture.create')}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each data as image}
|
||||
<a
|
||||
href="http://localhost:5175/gallery/{image.id}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="group relative aspect-square overflow-hidden rounded-lg bg-surface-hover"
|
||||
>
|
||||
<img
|
||||
src={image.thumbnailUrl || image.imageUrl}
|
||||
alt={truncatePrompt(image.prompt)}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<p class="p-2 text-xs text-white">{truncatePrompt(image.prompt, 30)}</p>
|
||||
</div>
|
||||
{#if image.isFavorite}
|
||||
<div class="absolute right-1 top-1 text-sm">❤️</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 text-center">
|
||||
<a
|
||||
href="http://localhost:5175/gallery"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm text-primary hover:underline"
|
||||
>
|
||||
{$_('dashboard.widgets.picture.view_all')}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
73
apps/manacore/apps/web/src/lib/config/default-dashboard.ts
Normal file
73
apps/manacore/apps/web/src/lib/config/default-dashboard.ts
Normal 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';
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
223
apps/manacore/apps/web/src/lib/stores/dashboard.svelte.ts
Normal file
223
apps/manacore/apps/web/src/lib/stores/dashboard.svelte.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
247
apps/manacore/apps/web/src/lib/types/dashboard.ts
Normal file
247
apps/manacore/apps/web/src/lib/types/dashboard.ts
Normal 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',
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue