feat(manacore): add clock, zitare, moodlit, skilltree, inventar modules + routes

Migrate 5 more apps to the unified same-origin app (Phase 2):
- Clock: world clocks, alarms, timers, stopwatch with stores + components
- Zitare: quotes, favorites, lists, spiral canvas with full route structure
- Moodlit: mood cards, sequences, fullscreen view with default moods
- Skilltree: skills, achievements, XP system with celebration components
- Inventar: collections, items, locations, categories (module only)

Also update landing page dates (Q2 2025 → Q3 2026).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 16:56:42 +02:00
parent aadd1c7538
commit e449172932
66 changed files with 9883 additions and 2 deletions

View file

@ -67,7 +67,7 @@ const { class: className } = Astro.props;
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<span class="inline-flex items-center gap-2 text-sm text-gray-400 dark:text-gray-500">
<Icon name="mdi:clock-outline" class="w-4 h-4" />
Q2 2025
Q3 2026
</span>
</div>
</div>

View file

@ -18,7 +18,7 @@ features:
- Offline-Modus für unterwegs
- Import/Export von Anki und Quizlet
status: coming-soon
releaseDate: Geplant Q2 2025
releaseDate: Geplant Q3 2026
order: 3
website: https://cards.ai
---

View file

@ -0,0 +1,43 @@
/**
* Clock module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalAlarm, LocalTimer, LocalWorldClock } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const alarmTable = db.table<LocalAlarm>('alarms');
export const timerTable = db.table<LocalTimer>('timers');
export const worldClockTable = db.table<LocalWorldClock>('worldClocks');
// ─── Guest Seed ────────────────────────────────────────────
export const CLOCK_GUEST_SEED = {
alarms: [
{
id: 'alarm-weekday-morning',
label: 'Wecker Wochentags',
time: '07:00',
enabled: true,
repeatDays: [1, 2, 3, 4, 5], // Mon-Fri
snoozeMinutes: 5,
sound: null,
vibrate: true,
},
] satisfies LocalAlarm[],
worldClocks: [
{
id: 'wc-new-york',
timezone: 'America/New_York',
cityName: 'New York',
sortOrder: 0,
},
{
id: 'wc-tokyo',
timezone: 'Asia/Tokyo',
cityName: 'Tokio',
sortOrder: 1,
},
] satisfies LocalWorldClock[],
};

View file

@ -0,0 +1,204 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
daysLived: number;
lifeExpectancyYears?: number;
size?: number;
}
let { daysLived, lifeExpectancyYears = 80, size = 280 }: Props = $props();
// Calculate progress
let totalDays = $derived(Math.ceil(lifeExpectancyYears * 365.25));
let percentage = $derived(Math.min((daysLived / totalDays) * 100, 100));
let remainingDays = $derived(Math.max(totalDays - daysLived, 0));
// SVG calculations
let strokeWidth = 12;
let radius = $derived((size - strokeWidth) / 2);
let circumference = $derived(2 * Math.PI * radius);
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
// Animation
let animatedOffset = $state(circumference);
let mounted = $state(false);
onMount(() => {
mounted = true;
// Animate on mount
requestAnimationFrame(() => {
animatedOffset = dashOffset;
});
});
// Update animation when values change
$effect(() => {
if (mounted) {
animatedOffset = dashOffset;
}
});
</script>
<div class="circular-container">
<div class="circular-wrapper" style="width: {size}px; height: {size}px;">
<svg width={size} height={size} viewBox="0 0 {size} {size}" class="circular-svg">
<!-- Background circle -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--color-muted-foreground) / 0.15)"
stroke-width={strokeWidth}
/>
<!-- Progress circle -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--color-primary))"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={animatedOffset}
transform="rotate(-90 {size / 2} {size / 2})"
class="progress-circle"
/>
<!-- Markers for decades -->
{#each Array(8) as _, i}
{@const angle = (i / 8) * 360 - 90}
{@const markerRadius = radius + strokeWidth / 2 + 8}
{@const x = size / 2 + markerRadius * Math.cos((angle * Math.PI) / 180)}
{@const y = size / 2 + markerRadius * Math.sin((angle * Math.PI) / 180)}
<text {x} {y} text-anchor="middle" dominant-baseline="middle" class="decade-marker">
{i * 10}
</text>
{/each}
</svg>
<!-- Center content -->
<div class="center-content">
<span class="percentage">{percentage.toFixed(1)}%</span>
<span class="label">gelebt</span>
</div>
</div>
<div class="circular-stats">
<div class="stat-row">
<div class="stat">
<span class="stat-value lived">{daysLived.toLocaleString('de-DE')}</span>
<span class="stat-label">Tage gelebt</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-value remaining">{remainingDays.toLocaleString('de-DE')}</span>
<span class="stat-label">Tage verbleibend</span>
</div>
</div>
<p class="expectancy-note">Basierend auf {lifeExpectancyYears} Jahren Lebenserwartung</p>
</div>
</div>
<style>
.circular-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.circular-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.circular-svg {
transform: rotate(0deg);
}
.progress-circle {
transition: stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.decade-marker {
font-size: 0.625rem;
fill: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.center-content {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.percentage {
font-size: 2.5rem;
font-weight: 200;
color: hsl(var(--color-foreground));
line-height: 1;
}
.label {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
.circular-stats {
text-align: center;
}
.stat-row {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-divider {
width: 1px;
height: 2.5rem;
background: hsl(var(--color-border));
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-value.lived {
color: hsl(var(--color-primary));
}
.stat-value.remaining {
color: hsl(var(--color-muted-foreground));
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.expectancy-note {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground) / 0.7);
margin-top: 0.75rem;
}
</style>

View file

@ -0,0 +1,111 @@
<script lang="ts">
/**
* WorldMap - Interactive world map component for world clock
*/
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { POPULAR_TIMEZONES } from '@clock/shared';
interface Props {
selectedTimezones?: string[];
onCityClick?: (timezone: string, cityName: string) => void;
}
let { selectedTimezones = [], onCityClick }: Props = $props();
let mapContainer: HTMLDivElement;
let mapLoaded = $state(false);
// Get cities from popular timezones
const cities = POPULAR_TIMEZONES.map((tz) => ({
timezone: tz.timezone,
city: tz.city,
lat: tz.lat,
lng: tz.lng,
}));
function handleCityClick(timezone: string, cityName: string) {
onCityClick?.(timezone, cityName);
}
onMount(() => {
if (browser) {
mapLoaded = true;
}
});
</script>
<div class="world-map" bind:this={mapContainer}>
{#if mapLoaded}
<div class="map-placeholder">
<svg viewBox="0 0 800 400" class="map-svg">
<!-- Simple world outline -->
<rect x="0" y="0" width="800" height="400" fill="hsl(var(--muted))" opacity="0.3" />
<!-- City markers -->
{#each cities as city}
{@const x = ((city.lng + 180) / 360) * 800}
{@const y = ((90 - city.lat) / 180) * 400}
{@const isSelected = selectedTimezones.includes(city.timezone)}
<g class="city-marker" onclick={() => handleCityClick(city.timezone, city.city)}>
<circle
cx={x}
cy={y}
r={isSelected ? 8 : 5}
fill={isSelected ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'}
class="cursor-pointer hover:opacity-80 transition-all"
/>
{#if isSelected}
<text
{x}
y={y - 12}
text-anchor="middle"
font-size="10"
fill="hsl(var(--foreground))"
class="pointer-events-none"
>
{city.city}
</text>
{/if}
</g>
{/each}
</svg>
</div>
{:else}
<div class="map-loading">
<span class="text-muted-foreground">Karte wird geladen...</span>
</div>
{/if}
</div>
<style>
.world-map {
width: 100%;
height: 300px;
background: hsl(var(--card));
border-radius: 12px;
overflow: hidden;
border: 1px solid hsl(var(--border));
}
.map-placeholder {
width: 100%;
height: 100%;
}
.map-svg {
width: 100%;
height: 100%;
}
.city-marker {
cursor: pointer;
}
.map-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View file

@ -0,0 +1,26 @@
/**
* Clock module barrel exports.
*/
export { alarmsStore } from './stores/alarms.svelte';
export { timersStore } from './stores/timers.svelte';
export { worldClocksStore } from './stores/world-clocks.svelte';
export { stopwatchesStore, formatTime, formatLapTime } from './stores/stopwatch.svelte';
export { sessionAlarmsStore } from './stores/session-alarms.svelte';
export { sessionTimersStore } from './stores/session-timers.svelte';
export {
useAllAlarms,
useAllTimers,
useAllWorldClocks,
allAlarms$,
allTimers$,
allWorldClocks$,
toAlarm,
toTimer,
toWorldClock,
filterEnabledAlarms,
filterActiveTimers,
sortWorldClocksByOrder,
} from './queries';
export { alarmTable, timerTable, worldClockTable, CLOCK_GUEST_SEED } from './collections';
export type { LocalAlarm, LocalTimer, LocalWorldClock } from './types';

View file

@ -0,0 +1,124 @@
/**
* Reactive Queries & Pure Helpers for Clock module.
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalAlarm, LocalTimer, LocalWorldClock } from './types';
import type { Alarm, Timer, WorldClock } from '@clock/shared';
// ─── Type Converters ───────────────────────────────────────
export function toAlarm(local: LocalAlarm): Alarm {
return {
id: local.id,
userId: 'local',
label: local.label,
time: local.time,
enabled: local.enabled,
repeatDays: local.repeatDays,
snoozeMinutes: local.snoozeMinutes,
sound: local.sound,
vibrate: local.vibrate ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTimer(local: LocalTimer): Timer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
status: local.status,
startedAt: local.startedAt,
pausedAt: local.pausedAt,
sound: local.sound,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toWorldClock(local: LocalWorldClock): WorldClock {
return {
id: local.id,
userId: 'local',
timezone: local.timezone,
cityName: local.cityName,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Raw Observable Queries (for Svelte $ auto-subscribe) ──
/** All alarms as raw observable. */
export function allAlarms$() {
return liveQuery(async () => {
const locals = await db.table<LocalAlarm>('alarms').toArray();
return locals.filter((a) => !a.deletedAt).map(toAlarm);
});
}
/** All timers as raw observable. */
export function allTimers$() {
return liveQuery(async () => {
const locals = await db.table<LocalTimer>('timers').toArray();
return locals.filter((t) => !t.deletedAt).map(toTimer);
});
}
/** All world clocks as raw observable, sorted by sortOrder. */
export function allWorldClocks$() {
return liveQuery(async () => {
const locals = await db.table<LocalWorldClock>('worldClocks').orderBy('sortOrder').toArray();
return locals.filter((wc) => !wc.deletedAt).map(toWorldClock);
});
}
// ─── Svelte 5 Reactive Hooks (call during component init) ──
/** All alarms, auto-updates on any change. Returns { value, loading, error }. */
export function useAllAlarms() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalAlarm>('alarms').toArray();
return locals.filter((a) => !a.deletedAt).map(toAlarm);
}, [] as Alarm[]);
}
/** All timers, auto-updates on any change. Returns { value, loading, error }. */
export function useAllTimers() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTimer>('timers').toArray();
return locals.filter((t) => !t.deletedAt).map(toTimer);
}, [] as Timer[]);
}
/** All world clocks, sorted by sortOrder. Returns { value, loading, error }. */
export function useAllWorldClocks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalWorldClock>('worldClocks').orderBy('sortOrder').toArray();
return locals.filter((wc) => !wc.deletedAt).map(toWorldClock);
}, [] as WorldClock[]);
}
// ─── Pure Filter Functions (for $derived) ──────────────────
export function filterEnabledAlarms(alarms: Alarm[]): Alarm[] {
return alarms.filter((a) => a.enabled);
}
export function filterActiveTimers(timers: Timer[]): Timer[] {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
}
export function sortWorldClocksByOrder(clocks: WorldClock[]): WorldClock[] {
return [...clocks].sort((a, b) => a.sortOrder - b.sortOrder);
}

View file

@ -0,0 +1,106 @@
/**
* Alarms Store Mutation-Only Service
*
* All reads are handled by liveQuery hooks in queries.ts.
* This store only provides write operations (create, update, delete, toggle).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { db } from '$lib/data/database';
import type { LocalAlarm } from '../types';
import { toAlarm } from '../queries';
import type { CreateAlarmInput, UpdateAlarmInput, Alarm } from '@clock/shared';
let error = $state<string | null>(null);
export const alarmsStore = {
get error() {
return error;
},
/**
* Create a new alarm -- writes to IndexedDB instantly.
*/
async createAlarm(input: CreateAlarmInput) {
error = null;
try {
const newLocal: LocalAlarm = {
id: crypto.randomUUID(),
label: input.label ?? null,
time: input.time,
enabled: input.enabled ?? true,
repeatDays: input.repeatDays ?? null,
snoozeMinutes: input.snoozeMinutes ?? null,
sound: input.sound ?? null,
vibrate: input.vibrate ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db.table<LocalAlarm>('alarms').add(newLocal);
return { success: true, data: toAlarm(newLocal) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create alarm';
console.error('Failed to create alarm:', e);
return { success: false, error: error };
}
},
/**
* Update an alarm -- writes to IndexedDB instantly.
*/
async updateAlarm(id: string, input: UpdateAlarmInput) {
error = null;
try {
const updateData: Partial<LocalAlarm> = {
updatedAt: new Date().toISOString(),
};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.time !== undefined) updateData.time = input.time;
if (input.enabled !== undefined) updateData.enabled = input.enabled;
if (input.repeatDays !== undefined) updateData.repeatDays = input.repeatDays ?? null;
if (input.snoozeMinutes !== undefined) updateData.snoozeMinutes = input.snoozeMinutes ?? null;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
if (input.vibrate !== undefined) updateData.vibrate = input.vibrate ?? null;
await db.table('alarms').update(id, updateData);
const updated = await db.table<LocalAlarm>('alarms').get(id);
if (updated) {
return { success: true, data: toAlarm(updated) };
}
return { success: false, error: 'Alarm not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update alarm';
console.error('Failed to update alarm:', e);
return { success: false, error: error };
}
},
/**
* Toggle alarm enabled state.
*/
async toggleAlarm(id: string, currentAlarms: Alarm[]) {
const alarm = currentAlarms.find((a) => a.id === id);
if (!alarm) return { success: false, error: 'Alarm not found' };
return this.updateAlarm(id, { enabled: !alarm.enabled });
},
/**
* Delete an alarm -- soft-deletes from IndexedDB instantly.
*/
async deleteAlarm(id: string) {
error = null;
try {
await db.table('alarms').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete alarm';
console.error('Failed to delete alarm:', e);
return { success: false, error: error };
}
},
};

View file

@ -0,0 +1,150 @@
/**
* Session Alarms Store - Manages alarms in sessionStorage for guest users
* This allows users to try the app without signing in.
* Data is stored in sessionStorage (lost when tab closes).
*/
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
const STORAGE_KEY = 'clock-session-alarms';
// State
let alarms = $state<Alarm[]>([]);
// Generate session ID
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Load from sessionStorage
function loadFromStorage(): void {
if (typeof window === 'undefined') return;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
alarms = JSON.parse(stored);
}
} catch (e) {
console.error('Failed to load session alarms:', e);
}
}
// Save to sessionStorage
function saveToStorage(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(alarms));
} catch (e) {
console.error('Failed to save session alarms:', e);
}
}
// Initialize on load
if (typeof window !== 'undefined') {
loadFromStorage();
}
export const sessionAlarmsStore = {
// Getters
get alarms() {
return alarms;
},
get enabledAlarms() {
return alarms.filter((a) => a.enabled);
},
/**
* Create a new session alarm
*/
createAlarm(input: CreateAlarmInput): Alarm {
const now = new Date().toISOString();
const alarm: Alarm = {
id: generateSessionId(),
userId: 'guest',
label: input.label || null,
time: input.time,
enabled: input.enabled ?? true,
repeatDays: input.repeatDays || null,
snoozeMinutes: input.snoozeMinutes || null,
sound: input.sound || null,
vibrate: input.vibrate ?? null,
createdAt: now,
updatedAt: now,
};
alarms = [...alarms, alarm];
saveToStorage();
return alarm;
},
/**
* Update a session alarm
*/
updateAlarm(id: string, input: UpdateAlarmInput): Alarm | null {
const index = alarms.findIndex((a) => a.id === id);
if (index === -1) return null;
const updated: Alarm = {
...alarms[index],
...input,
updatedAt: new Date().toISOString(),
};
alarms = alarms.map((a) => (a.id === id ? updated : a));
saveToStorage();
return updated;
},
/**
* Toggle alarm enabled state
*/
toggleAlarm(id: string): Alarm | null {
const alarm = alarms.find((a) => a.id === id);
if (!alarm) return null;
return this.updateAlarm(id, { enabled: !alarm.enabled });
},
/**
* Delete a session alarm
*/
deleteAlarm(id: string): void {
alarms = alarms.filter((a) => a.id !== id);
saveToStorage();
},
/**
* Check if ID is a session alarm
*/
isSessionAlarm(id: string): boolean {
return id.startsWith('session_');
},
/**
* Get all alarms for migration
*/
getAllAlarms(): Alarm[] {
return [...alarms];
},
/**
* Clear all session data
*/
clear(): void {
alarms = [];
if (typeof window !== 'undefined') {
sessionStorage.removeItem(STORAGE_KEY);
}
},
/**
* Get count of session alarms
*/
get count(): number {
return alarms.length;
},
};

View file

@ -0,0 +1,214 @@
/**
* Session Timers Store - Manages timers in sessionStorage for guest users
* This allows users to try the app without signing in.
* Data is stored in sessionStorage (lost when tab closes).
*/
import type { Timer, CreateTimerInput, UpdateTimerInput, TimerStatus } from '@clock/shared';
const STORAGE_KEY = 'clock-session-timers';
// State
let timers = $state<Timer[]>([]);
// Generate session ID
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Load from sessionStorage
function loadFromStorage(): void {
if (typeof window === 'undefined') return;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
timers = JSON.parse(stored);
}
} catch (e) {
console.error('Failed to load session timers:', e);
}
}
// Save to sessionStorage
function saveToStorage(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(timers));
} catch (e) {
console.error('Failed to save session timers:', e);
}
}
// Initialize on load
if (typeof window !== 'undefined') {
loadFromStorage();
}
export const sessionTimersStore = {
// Getters
get timers() {
return timers;
},
get activeTimers() {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
},
/**
* Create a new session timer
*/
createTimer(input: CreateTimerInput): Timer {
const now = new Date().toISOString();
const timer: Timer = {
id: generateSessionId(),
userId: 'guest',
label: input.label || null,
durationSeconds: input.durationSeconds,
remainingSeconds: input.durationSeconds,
status: 'idle' as TimerStatus,
startedAt: null,
pausedAt: null,
sound: input.sound || null,
createdAt: now,
updatedAt: now,
};
timers = [...timers, timer];
saveToStorage();
return timer;
},
/**
* Update a session timer
*/
updateTimer(id: string, input: UpdateTimerInput): Timer | null {
const index = timers.findIndex((t) => t.id === id);
if (index === -1) return null;
const updated: Timer = {
...timers[index],
...input,
updatedAt: new Date().toISOString(),
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
/**
* Start a timer
*/
startTimer(id: string): Timer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: Timer = {
...timer,
status: 'running',
startedAt: now,
pausedAt: null,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
/**
* Pause a timer
*/
pauseTimer(id: string): Timer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: Timer = {
...timer,
status: 'paused',
pausedAt: now,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
/**
* Reset a timer
*/
resetTimer(id: string): Timer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: Timer = {
...timer,
status: 'idle',
remainingSeconds: timer.durationSeconds,
startedAt: null,
pausedAt: null,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
/**
* Update local timer state (for countdown display)
*/
updateLocalState(id: string, updates: Partial<Timer>): void {
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
saveToStorage();
},
/**
* Delete a session timer
*/
deleteTimer(id: string): void {
timers = timers.filter((t) => t.id !== id);
saveToStorage();
},
/**
* Check if ID is a session timer
*/
isSessionTimer(id: string): boolean {
return id.startsWith('session_');
},
/**
* Get all timers for migration
*/
getAllTimers(): Timer[] {
return [...timers];
},
/**
* Clear all session data
*/
clear(): void {
timers = [];
if (typeof window !== 'undefined') {
sessionStorage.removeItem(STORAGE_KEY);
}
},
/**
* Get count of session timers
*/
get count(): number {
return timers.length;
},
};

View file

@ -0,0 +1,231 @@
/**
* Stopwatch Store - Manages stopwatch state using Svelte 5 runes
* Stopwatches are local-only (no backend sync)
*/
export interface Lap {
number: number;
time: number; // milliseconds since start
delta: number; // milliseconds since last lap
}
export interface Stopwatch {
id: string;
label: string;
startTime: number | null; // timestamp when started
elapsedTime: number; // accumulated milliseconds when paused
status: 'idle' | 'running' | 'paused';
laps: Lap[];
color: string;
}
export const STOPWATCH_COLORS = [
'#3B82F6', // blue
'#10B981', // green
'#F59E0B', // amber
'#EF4444', // red
'#8B5CF6', // violet
'#EC4899', // pink
'#14B8A6', // teal
'#F97316', // orange
];
// State
let stopwatches = $state<Stopwatch[]>([]);
let focusedId = $state<string | null>(null);
let colorIndex = 0;
// Tick interval for updating display
let tickInterval: ReturnType<typeof setInterval> | null = null;
function getNextColor(): string {
const color = STOPWATCH_COLORS[colorIndex % STOPWATCH_COLORS.length];
colorIndex++;
return color;
}
function startTicking() {
if (tickInterval) return;
tickInterval = setInterval(() => {
// Force reactivity update by reassigning
stopwatches = [...stopwatches];
}, 100);
}
function stopTickingIfNoRunning() {
const hasRunning = stopwatches.some((sw) => sw.status === 'running');
if (!hasRunning && tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
}
export const stopwatchesStore = {
// Getters
get stopwatches() {
return stopwatches;
},
get focusedId() {
return focusedId;
},
get focusedStopwatch() {
return stopwatches.find((sw) => sw.id === focusedId) || null;
},
/**
* Create a new stopwatch
*/
create(label?: string): string {
const id = crypto.randomUUID();
const newStopwatch: Stopwatch = {
id,
label: label || `Stopwatch ${stopwatches.length + 1}`,
startTime: null,
elapsedTime: 0,
status: 'idle',
laps: [],
color: getNextColor(),
};
stopwatches = [...stopwatches, newStopwatch];
if (!focusedId) {
focusedId = id;
}
return id;
},
/**
* Start a stopwatch
*/
start(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id) return sw;
return {
...sw,
startTime: Date.now(),
status: 'running' as const,
};
});
startTicking();
},
/**
* Pause a stopwatch
*/
pause(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id || sw.status !== 'running') return sw;
const elapsed = sw.startTime ? Date.now() - sw.startTime : 0;
return {
...sw,
startTime: null,
elapsedTime: sw.elapsedTime + elapsed,
status: 'paused' as const,
};
});
stopTickingIfNoRunning();
},
/**
* Reset a stopwatch
*/
reset(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id) return sw;
return {
...sw,
startTime: null,
elapsedTime: 0,
status: 'idle' as const,
laps: [],
};
});
stopTickingIfNoRunning();
},
/**
* Add a lap to a stopwatch
*/
addLap(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id || sw.status !== 'running') return sw;
const currentTime = this.getElapsed(sw);
const lastLap = sw.laps[sw.laps.length - 1];
const delta = lastLap ? currentTime - lastLap.time : currentTime;
const newLap: Lap = {
number: sw.laps.length + 1,
time: currentTime,
delta,
};
return {
...sw,
laps: [...sw.laps, newLap],
};
});
},
/**
* Delete a stopwatch
*/
delete(id: string) {
stopwatches = stopwatches.filter((sw) => sw.id !== id);
if (focusedId === id) {
focusedId = stopwatches[0]?.id || null;
}
stopTickingIfNoRunning();
},
/**
* Set focused stopwatch
*/
setFocused(id: string | null) {
focusedId = id;
},
/**
* Update stopwatch label
*/
updateLabel(id: string, label: string) {
stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, label } : sw));
},
/**
* Get elapsed time for a stopwatch
*/
getElapsed(sw: Stopwatch): number {
if (sw.status === 'running' && sw.startTime) {
return sw.elapsedTime + (Date.now() - sw.startTime);
}
return sw.elapsedTime;
},
};
/**
* Format time in milliseconds to display string
*/
export function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((ms % 1000) / 10);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
/**
* Format lap time (delta) for display
*/
export function formatLapTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((ms % 1000) / 10);
if (minutes > 0) {
return `+${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
return `+${seconds}.${centiseconds.toString().padStart(2, '0')}`;
}

View file

@ -0,0 +1,206 @@
/**
* Timers Store Mutation-Only Service
*
* All reads are handled by liveQuery hooks in queries.ts.
* This store only provides write operations (create, update, delete, start, pause, reset).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { db } from '$lib/data/database';
import type { LocalTimer } from '../types';
import { toTimer } from '../queries';
import type { CreateTimerInput, UpdateTimerInput } from '@clock/shared';
import { ClockEvents } from '@manacore/shared-utils/analytics';
let error = $state<string | null>(null);
export const timersStore = {
get error() {
return error;
},
/**
* Create a new timer -- writes to IndexedDB instantly.
*/
async createTimer(input: CreateTimerInput) {
error = null;
try {
const newLocal: LocalTimer = {
id: crypto.randomUUID(),
label: input.label ?? null,
durationSeconds: input.durationSeconds,
remainingSeconds: null,
status: 'idle',
startedAt: null,
pausedAt: null,
sound: input.sound ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db.table<LocalTimer>('timers').add(newLocal);
return { success: true, data: toTimer(newLocal) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create timer';
console.error('Failed to create timer:', e);
return { success: false, error: error };
}
},
/**
* Update a timer -- writes to IndexedDB instantly.
*/
async updateTimer(id: string, input: UpdateTimerInput) {
error = null;
try {
const updateData: Partial<LocalTimer> = {
updatedAt: new Date().toISOString(),
};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.durationSeconds !== undefined) updateData.durationSeconds = input.durationSeconds;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
await db.table('timers').update(id, updateData);
const updated = await db.table<LocalTimer>('timers').get(id);
if (updated) {
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update timer';
console.error('Failed to update timer:', e);
return { success: false, error: error };
}
},
/**
* Start a timer -- sets status to running with current timestamp.
*/
async startTimer(id: string) {
error = null;
try {
const existing = await db.table<LocalTimer>('timers').get(id);
if (!existing) return { success: false, error: 'Timer not found' };
const updateData: Partial<LocalTimer> = {
status: 'running',
startedAt: new Date().toISOString(),
pausedAt: null,
updatedAt: new Date().toISOString(),
};
// If resuming from pause, keep remaining seconds
if (existing.status !== 'paused') {
updateData.remainingSeconds = existing.durationSeconds;
}
await db.table('timers').update(id, updateData);
const updated = await db.table<LocalTimer>('timers').get(id);
if (updated) {
const updatedTimer = toTimer(updated);
ClockEvents.timerStarted(
(updatedTimer as any).type as 'pomodoro' | 'stopwatch' | 'countdown'
);
return { success: true, data: updatedTimer };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to start timer';
console.error('Failed to start timer:', e);
return { success: false, error: error };
}
},
/**
* Pause a timer -- calculates remaining seconds and saves.
*/
async pauseTimer(id: string) {
error = null;
try {
const existing = await db.table<LocalTimer>('timers').get(id);
if (!existing) return { success: false, error: 'Timer not found' };
// Calculate remaining seconds
let remaining = existing.remainingSeconds ?? existing.durationSeconds;
if (existing.startedAt) {
const elapsed = (Date.now() - new Date(existing.startedAt).getTime()) / 1000;
remaining = Math.max(0, remaining - elapsed);
}
const updateData: Partial<LocalTimer> = {
status: 'paused',
pausedAt: new Date().toISOString(),
remainingSeconds: Math.round(remaining),
startedAt: null,
updatedAt: new Date().toISOString(),
};
await db.table('timers').update(id, updateData);
const updated = await db.table<LocalTimer>('timers').get(id);
if (updated) {
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to pause timer';
console.error('Failed to pause timer:', e);
return { success: false, error: error };
}
},
/**
* Reset a timer -- back to idle with full duration.
*/
async resetTimer(id: string) {
error = null;
try {
const updateData: Partial<LocalTimer> = {
status: 'idle',
remainingSeconds: null,
startedAt: null,
pausedAt: null,
updatedAt: new Date().toISOString(),
};
await db.table('timers').update(id, updateData);
const updated = await db.table<LocalTimer>('timers').get(id);
if (updated) {
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reset timer';
console.error('Failed to reset timer:', e);
return { success: false, error: error };
}
},
/**
* Delete a timer -- soft-deletes from IndexedDB instantly.
*/
async deleteTimer(id: string) {
error = null;
try {
await db.table('timers').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete timer';
console.error('Failed to delete timer:', e);
return { success: false, error: error };
}
},
/**
* Update remaining seconds in IndexedDB (for countdown display).
*/
async updateLocalTimer(id: string, remainingSeconds: number) {
try {
await db.table('timers').update(id, { remainingSeconds });
} catch (e) {
console.error('Failed to update local timer:', e);
}
},
};

View file

@ -0,0 +1,82 @@
/**
* World Clocks Store Mutation-Only Service
*
* All reads are handled by liveQuery hooks in queries.ts.
* This store only provides write operations (add, remove, reorder).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { db } from '$lib/data/database';
import type { LocalWorldClock } from '../types';
import type { CreateWorldClockInput } from '@clock/shared';
let error = $state<string | null>(null);
export const worldClocksStore = {
get error() {
return error;
},
/**
* Add a new world clock -- writes to IndexedDB instantly.
*/
async addWorldClock(input: CreateWorldClockInput, currentCount: number = 0) {
error = null;
try {
const newLocal: LocalWorldClock = {
id: crypto.randomUUID(),
timezone: input.timezone,
cityName: input.cityName,
sortOrder: currentCount,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db.table<LocalWorldClock>('worldClocks').add(newLocal);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add world clock';
console.error('Failed to add world clock:', e);
return { success: false, error: error };
}
},
/**
* Remove a world clock -- soft-deletes from IndexedDB instantly.
*/
async removeWorldClock(id: string) {
error = null;
try {
await db.table('worldClocks').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove world clock';
console.error('Failed to remove world clock:', e);
return { success: false, error: error };
}
},
/**
* Reorder world clocks -- updates sortOrder in IndexedDB.
*/
async reorder(ids: string[]) {
error = null;
try {
const now = new Date().toISOString();
for (let i = 0; i < ids.length; i++) {
await db.table('worldClocks').update(ids[i], {
sortOrder: i,
updatedAt: now,
});
}
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder world clocks';
console.error('Failed to reorder world clocks:', e);
return { success: false, error: error };
}
},
};

View file

@ -0,0 +1,31 @@
/**
* Clock module types for the unified ManaCore app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalAlarm extends BaseRecord {
label: string | null;
time: string; // HH:mm format
enabled: boolean;
repeatDays: number[] | null; // [0-6] where 0 = Sunday
snoozeMinutes: number | null;
sound: string | null;
vibrate: boolean | null;
}
export interface LocalTimer extends BaseRecord {
label: string | null;
durationSeconds: number;
remainingSeconds: number | null;
status: 'idle' | 'running' | 'paused' | 'finished';
startedAt: string | null;
pausedAt: string | null;
sound: string | null;
}
export interface LocalWorldClock extends BaseRecord {
timezone: string; // IANA timezone e.g. 'America/New_York'
cityName: string;
sortOrder: number;
}

View file

@ -0,0 +1,109 @@
/**
* Inventar module collection accessors and guest seed data.
*
* Uses prefixed table names in the unified DB: invCollections, invItems, invLocations, invCategories.
*/
import { db } from '$lib/data/database';
import type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const invCollectionTable = db.table<LocalCollection>('invCollections');
export const invItemTable = db.table<LocalItem>('invItems');
export const invLocationTable = db.table<LocalLocation>('invLocations');
export const invCategoryTable = db.table<LocalCategory>('invCategories');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_COLLECTION_ID = 'demo-electronics';
const DEMO_LOCATION_ID = 'demo-home';
const DEMO_CATEGORY_ID = 'demo-tech';
export const INVENTAR_GUEST_SEED = {
invCollections: [
{
id: DEMO_COLLECTION_ID,
name: 'Meine Elektronik',
description: 'Beispiel-Sammlung zum Kennenlernen von Inventar.',
icon: '💻',
color: '#3b82f6',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'model', name: 'Modell', type: 'text', order: 1 },
{ id: 'serial', name: 'Seriennummer', type: 'text', order: 2 },
],
},
order: 0,
itemCount: 2,
},
],
invItems: [
{
id: 'item-laptop',
collectionId: DEMO_COLLECTION_ID,
locationId: DEMO_LOCATION_ID,
categoryId: DEMO_CATEGORY_ID,
name: 'MacBook Pro',
description: 'Arbeits-Laptop',
status: 'owned' as const,
quantity: 1,
fieldValues: { brand: 'Apple', model: 'MacBook Pro 14"', serial: 'ABC123' },
photos: [],
notes: [],
tags: ['arbeit'],
order: 0,
},
{
id: 'item-headphones',
collectionId: DEMO_COLLECTION_ID,
locationId: DEMO_LOCATION_ID,
name: 'Kopfhorer',
description: 'Noise-Cancelling Kopfhorer',
status: 'owned' as const,
quantity: 1,
fieldValues: { brand: 'Sony', model: 'WH-1000XM5' },
photos: [],
notes: [],
tags: ['audio'],
order: 1,
},
],
invLocations: [
{
id: DEMO_LOCATION_ID,
name: 'Zuhause',
description: 'Mein Zuhause',
icon: '🏠',
path: 'Zuhause',
depth: 0,
order: 0,
},
{
id: 'demo-office',
parentId: DEMO_LOCATION_ID,
name: 'Buro',
icon: '🖥️',
path: 'Zuhause/Buro',
depth: 1,
order: 0,
},
],
invCategories: [
{
id: DEMO_CATEGORY_ID,
name: 'Technik',
icon: '⚡',
color: '#6366f1',
order: 0,
},
{
id: 'demo-audio',
name: 'Audio',
icon: '🎧',
color: '#ec4899',
order: 1,
},
],
};

View file

@ -0,0 +1,324 @@
/**
* Reactive queries & pure helpers for Inventar uses Dexie liveQuery on the unified DB.
*
* Uses prefixed table names: invCollections, invItems, invLocations, invCategories.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './types';
// ─── Shared Types (inline to avoid @inventar/shared dependency) ───
export interface Collection {
id: string;
name: string;
description?: string;
icon?: string;
color?: string;
schema: LocalCollection['schema'];
templateId?: string;
order: number;
itemCount: number;
createdAt: string;
updatedAt: string;
}
export interface Item {
id: string;
collectionId: string;
locationId?: string;
categoryId?: string;
name: string;
description?: string;
status: 'owned' | 'lent' | 'stored' | 'for_sale' | 'disposed';
quantity: number;
fieldValues: Record<string, unknown>;
purchaseData?: LocalItem['purchaseData'];
photos: LocalItem['photos'];
notes: LocalItem['notes'];
documents: never[];
tags: string[];
order: number;
createdAt: string;
updatedAt: string;
}
export type ItemStatus = Item['status'];
export interface Location {
id: string;
parentId?: string;
name: string;
description?: string;
icon?: string;
path: string;
depth: number;
order: number;
createdAt: string;
updatedAt: string;
children?: Location[];
}
export interface Category {
id: string;
parentId?: string;
name: string;
icon?: string;
color?: string;
order: number;
createdAt: string;
updatedAt: string;
children?: Category[];
}
export type ViewMode = 'list' | 'grid' | 'table';
export interface SortOption {
field: 'name' | 'createdAt' | 'updatedAt' | 'status' | 'quantity';
direction: 'asc' | 'desc';
}
export interface FilterCriteria {
search?: string;
collectionId?: string;
locationId?: string;
categoryId?: string;
status?: ItemStatus[];
tagIds?: string[];
}
// ─── Type Converters ───────────────────────────────────────
export function toCollection(local: LocalCollection): Collection {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
icon: local.icon ?? undefined,
color: local.color ?? undefined,
schema: local.schema,
templateId: local.templateId ?? undefined,
order: local.order,
itemCount: local.itemCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toItem(local: LocalItem): Item {
return {
id: local.id,
collectionId: local.collectionId,
locationId: local.locationId ?? undefined,
categoryId: local.categoryId ?? undefined,
name: local.name,
description: local.description ?? undefined,
status: local.status,
quantity: local.quantity,
fieldValues: local.fieldValues,
purchaseData: local.purchaseData ?? undefined,
photos: local.photos,
notes: local.notes,
documents: [],
tags: local.tags,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toLocation(local: LocalLocation): Location {
return {
id: local.id,
parentId: local.parentId ?? undefined,
name: local.name,
description: local.description ?? undefined,
icon: local.icon ?? undefined,
path: local.path,
depth: local.depth,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toCategory(local: LocalCategory): Category {
return {
id: local.id,
parentId: local.parentId ?? undefined,
name: local.name,
icon: local.icon ?? undefined,
color: local.color ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllCollections() {
return liveQuery(async () => {
const locals = await db.table<LocalCollection>('invCollections').toArray();
return locals.filter((c) => !c.deletedAt).map(toCollection);
});
}
export function useAllItems() {
return liveQuery(async () => {
const locals = await db.table<LocalItem>('invItems').toArray();
return locals.filter((i) => !i.deletedAt).map(toItem);
});
}
export function useAllLocations() {
return liveQuery(async () => {
const locals = await db.table<LocalLocation>('invLocations').toArray();
return locals.filter((l) => !l.deletedAt).map(toLocation);
});
}
export function useAllCategories() {
return liveQuery(async () => {
const locals = await db.table<LocalCategory>('invCategories').toArray();
return locals.filter((c) => !c.deletedAt).map(toCategory);
});
}
// ─── Pure Collection Helpers ──────────────────────────────
export function getCollectionById(collections: Collection[], id: string): Collection | undefined {
return collections.find((c) => c.id === id);
}
export function getSortedCollections(collections: Collection[]): Collection[] {
return [...collections].sort((a, b) => a.order - b.order);
}
// ─── Pure Item Helpers ────────────────────────────────────
export function getItemById(items: Item[], id: string): Item | undefined {
return items.find((i) => i.id === id);
}
export function getItemsByCollection(items: Item[], collectionId: string): Item[] {
return items.filter((i) => i.collectionId === collectionId);
}
export function getItemCountByCollection(items: Item[], collectionId: string): number {
return items.filter((i) => i.collectionId === collectionId).length;
}
export function getTotalItemCount(items: Item[]): number {
return items.length;
}
export function getFilteredItems(items: Item[], filters: FilterCriteria): Item[] {
let result = items;
if (filters.collectionId) {
result = result.filter((i) => i.collectionId === filters.collectionId);
}
if (filters.locationId) {
result = result.filter((i) => i.locationId === filters.locationId);
}
if (filters.categoryId) {
result = result.filter((i) => i.categoryId === filters.categoryId);
}
if (filters.status?.length) {
result = result.filter((i) => filters.status!.includes(i.status));
}
if (filters.tagIds?.length) {
result = result.filter((i) => filters.tagIds!.some((t) => i.tags.includes(t)));
}
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.description?.toLowerCase().includes(q) ||
Object.values(i.fieldValues).some((v) => String(v).toLowerCase().includes(q))
);
}
return result;
}
export function getSortedItems(itemList: Item[], sort: SortOption): Item[] {
return [...itemList].sort((a, b) => {
let cmp = 0;
switch (sort.field) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'createdAt':
cmp = a.createdAt.localeCompare(b.createdAt);
break;
case 'updatedAt':
cmp = a.updatedAt.localeCompare(b.updatedAt);
break;
case 'status':
cmp = a.status.localeCompare(b.status);
break;
case 'quantity':
cmp = a.quantity - b.quantity;
break;
}
return sort.direction === 'desc' ? -cmp : cmp;
});
}
// ─── Pure Location Helpers ────────────────────────────────
export function getLocationById(locations: Location[], id: string): Location | undefined {
return locations.find((l) => l.id === id);
}
export function getRootLocations(locations: Location[]): Location[] {
return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order);
}
export function getLocationChildren(locations: Location[], parentId: string): Location[] {
return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order);
}
export function getLocationTree(locations: Location[]): Location[] {
const buildTree = (parentId?: string): Location[] => {
return locations
.filter((l) => l.parentId === parentId)
.sort((a, b) => a.order - b.order)
.map((l) => ({ ...l, children: buildTree(l.id) }));
};
return buildTree(undefined);
}
export function getLocationFullPath(locations: Location[], id: string): string {
const location = locations.find((l) => l.id === id);
if (!location) return '';
return location.path ? `${location.path}/${location.name}` : location.name;
}
// ─── Pure Category Helpers ────────────────────────────────
export function getCategoryById(categories: Category[], id: string): Category | undefined {
return categories.find((c) => c.id === id);
}
export function getRootCategories(categories: Category[]): Category[] {
return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order);
}
export function getCategoryChildren(categories: Category[], parentId: string): Category[] {
return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order);
}
export function getCategoryTree(categories: Category[]): Category[] {
const buildTree = (parentId?: string): Category[] => {
return categories
.filter((c) => c.parentId === parentId)
.sort((a, b) => a.order - b.order)
.map((c) => ({ ...c, children: buildTree(c.id) }));
};
return buildTree(undefined);
}

View file

@ -0,0 +1,68 @@
/**
* Inventar module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalCollection extends BaseRecord {
name: string;
description?: string | null;
icon?: string | null;
color?: string | null;
schema: {
fields: Array<{
id: string;
name: string;
type: string;
required?: boolean;
defaultValue?: unknown;
options?: string[];
currencyCode?: string;
placeholder?: string;
order: number;
}>;
};
templateId?: string | null;
order: number;
itemCount: number;
}
export interface LocalItem extends BaseRecord {
collectionId: string;
locationId?: string | null;
categoryId?: string | null;
name: string;
description?: string | null;
status: 'owned' | 'lent' | 'stored' | 'for_sale' | 'disposed';
quantity: number;
fieldValues: Record<string, unknown>;
purchaseData?: {
price?: number;
currency?: string;
date?: string;
retailer?: string;
warrantyExpiry?: string;
} | null;
photos: Array<{ id: string; url: string; caption?: string; order: number }>;
notes: Array<{ id: string; content: string; createdAt: string }>;
tags: string[];
order: number;
}
export interface LocalLocation extends BaseRecord {
parentId?: string | null;
name: string;
description?: string | null;
icon?: string | null;
path: string;
depth: number;
order: number;
}
export interface LocalCategory extends BaseRecord {
parentId?: string | null;
name: string;
icon?: string | null;
color?: string | null;
order: number;
}

View file

@ -0,0 +1,88 @@
/**
* Moodlit module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalMood, LocalSequence } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const moodTable = db.table<LocalMood>('moods');
export const sequenceTable = db.table<LocalSequence>('sequences');
// ─── Guest Seed ────────────────────────────────────────────
export const MOODLIT_GUEST_SEED = {
moods: [
{
id: 'fire',
name: 'Fire',
colors: ['#ff6b35', '#f72585', '#ff006e'],
animation: 'flicker',
isDefault: true,
},
{
id: 'breath',
name: 'Breath',
colors: ['#4361ee', '#3a0ca3', '#7209b7'],
animation: 'pulse',
isDefault: true,
},
{
id: 'northern-lights',
name: 'Northern Lights',
colors: ['#06d6a0', '#118ab2', '#073b4c'],
animation: 'aurora',
isDefault: true,
},
{
id: 'sunset',
name: 'Sunset',
colors: ['#ff6b6b', '#feca57', '#ff9ff3'],
animation: 'gradient',
isDefault: true,
},
{
id: 'ocean',
name: 'Ocean',
colors: ['#0077b6', '#00b4d8', '#90e0ef'],
animation: 'wave',
isDefault: true,
},
{
id: 'forest',
name: 'Forest',
colors: ['#2d6a4f', '#40916c', '#52b788'],
animation: 'sway',
isDefault: true,
},
{
id: 'lavender',
name: 'Lavender',
colors: ['#7b2cbf', '#9d4edd', '#c77dff'],
animation: 'pulse',
isDefault: true,
},
{
id: 'thunder',
name: 'Thunder',
colors: ['#14213d', '#fca311', '#e5e5e5'],
animation: 'flash',
isDefault: true,
},
],
sequences: [
{
id: 'evening-flow',
name: 'Evening Flow',
moodIds: ['sunset', 'lavender', 'breath'],
duration: 30,
},
{
id: 'nature',
name: 'Nature',
moodIds: ['forest', 'ocean', 'northern-lights'],
duration: 45,
},
],
};

View file

@ -0,0 +1,215 @@
<script lang="ts">
import { X, Plus, Trash } from '@manacore/shared-icons';
import type { Mood, AnimationType } from '$lib/modules/moodlit/types';
import { ANIMATIONS } from '$lib/modules/moodlit/types';
import { getMoodGradient } from '$lib/modules/moodlit/default-moods';
interface Props {
isOpen: boolean;
onClose: () => void;
onSave: (mood: Omit<Mood, 'id' | 'isCustom' | 'order' | 'createdAt'>) => void;
editMood?: Mood | null;
}
let { isOpen, onClose, onSave, editMood = null }: Props = $props();
let name = $state('');
let colors = $state<string[]>(['#667eea', '#764ba2']);
let animationType = $state<AnimationType>('gradient');
let previewMood = $derived<Mood>({
id: 'preview',
name: name || 'Preview',
colors,
animationType,
});
$effect(() => {
if (isOpen) {
if (editMood) {
name = editMood.name;
colors = [...editMood.colors];
animationType = editMood.animationType;
} else {
name = '';
colors = ['#667eea', '#764ba2'];
animationType = 'gradient';
}
}
});
function addColor() {
if (colors.length < 8) {
const randomColor =
'#' +
Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0');
colors = [...colors, randomColor];
}
}
function removeColor(index: number) {
if (colors.length > 1) {
colors = colors.filter((_, i) => i !== index);
}
}
function updateColor(index: number, value: string) {
colors = colors.map((c, i) => (i === index ? value : c));
}
function handleSubmit() {
if (!name.trim()) return;
if (colors.length === 0) return;
onSave({
name: name.trim(),
colors,
animationType,
});
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onclick={onClose}
role="presentation"
></div>
<!-- Dialog -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div
class="bg-[hsl(var(--color-background))] rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
role="dialog"
aria-modal="true"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-border">
<h2 class="text-xl font-semibold">
{editMood ? 'Mood bearbeiten' : 'Neues Mood erstellen'}
</h2>
<button
type="button"
class="p-2 rounded-lg hover:bg-muted transition-colors"
onclick={onClose}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<!-- Content -->
<div class="p-4 space-y-6">
<!-- Preview -->
<div class="relative rounded-xl overflow-hidden aspect-video">
<div class="absolute inset-0" style="background: {getMoodGradient(previewMood)};"></div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"
></div>
<div class="absolute inset-x-0 bottom-0 p-4">
<h3 class="text-lg font-semibold text-white drop-shadow-md">
{previewMood.name}
</h3>
<p class="text-sm text-white/70 capitalize">{previewMood.animationType}</p>
</div>
</div>
<!-- Name Input -->
<div class="space-y-2">
<label for="mood-name" class="text-sm font-medium">Name</label>
<input
id="mood-name"
type="text"
bind:value={name}
placeholder="Mood Name..."
class="w-full px-4 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<!-- Colors -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium">Farben</label>
<button
type="button"
class="flex items-center gap-1 px-2 py-1 text-sm rounded-lg hover:bg-muted transition-colors"
onclick={addColor}
disabled={colors.length >= 8}
>
<Plus size={16} />
Farbe hinzufugen
</button>
</div>
<div class="flex flex-wrap gap-2">
{#each colors as color, i}
<div class="flex items-center gap-1">
<input
type="color"
value={color}
onchange={(e) => updateColor(i, e.currentTarget.value)}
class="w-10 h-10 rounded-lg border border-border cursor-pointer"
/>
{#if colors.length > 1}
<button
type="button"
class="p-1 rounded hover:bg-red-500/20 text-red-500 transition-colors"
onclick={() => removeColor(i)}
aria-label="Remove color"
>
<Trash size={16} />
</button>
{/if}
</div>
{/each}
</div>
</div>
<!-- Animation Type -->
<div class="space-y-2">
<label for="animation-type" class="text-sm font-medium">Animation</label>
<select
id="animation-type"
bind:value={animationType}
class="w-full px-4 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{#each ANIMATIONS as anim}
<option value={anim.id}>{anim.name} - {anim.description}</option>
{/each}
</select>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 p-4 border-t border-border">
<button
type="button"
class="px-4 py-2 rounded-lg hover:bg-muted transition-colors"
onclick={onClose}
>
Abbrechen
</button>
<button
type="button"
class="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSubmit}
disabled={!name.trim() || colors.length === 0}
>
Speichern
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,188 @@
<script lang="ts">
import type { Mood } from '$lib/modules/moodlit/types';
import { getMoodGradient } from '$lib/modules/moodlit/default-moods';
import { Heart } from '@manacore/shared-icons';
interface Props {
mood: Mood;
isActive?: boolean;
isFavorite?: boolean;
showFavorite?: boolean;
onClick?: () => void;
onFavoriteToggle?: () => void;
}
let {
mood,
isActive = false,
isFavorite = false,
showFavorite = true,
onClick,
onFavoriteToggle,
}: Props = $props();
const gradient = $derived(getMoodGradient(mood));
const animationClass = $derived(getAnimationClass(mood.animationType));
function getAnimationClass(type: string): string {
switch (type) {
case 'pulse':
case 'breath':
return 'animate-pulse-slow';
case 'wave':
return 'animate-wave';
case 'candle':
return 'animate-candle';
case 'disco':
case 'rave':
return 'animate-disco';
case 'thunder':
return 'animate-thunder';
default:
return '';
}
}
function handleFavoriteClick(e: MouseEvent) {
e.stopPropagation();
onFavoriteToggle?.();
}
function handleClick() {
onClick?.();
}
</script>
<button
type="button"
class="mood-card group relative w-full overflow-hidden rounded-2xl transition-all duration-200 hover:scale-[1.02] hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
class:ring-2={isActive}
class:ring-primary={isActive}
onclick={handleClick}
>
<!-- Gradient Background -->
<div class="aspect-[16/10] w-full {animationClass}" style="background: {gradient};"></div>
<!-- Overlay gradient for text readability -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"></div>
<!-- Content -->
<div class="absolute inset-x-0 bottom-0 p-4">
<div class="flex items-end justify-between">
<div class="text-left">
<h3 class="font-semibold text-white drop-shadow-md">{mood.name}</h3>
<p class="text-xs text-white/70 capitalize">{mood.animationType}</p>
</div>
{#if showFavorite}
<button
type="button"
class="rounded-full p-1.5 transition-colors hover:bg-white/20"
onclick={handleFavoriteClick}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={20}
weight={isFavorite ? 'fill' : 'regular'}
class={isFavorite ? 'text-red-500' : 'text-white/70'}
/>
</button>
{/if}
</div>
</div>
<!-- Custom badge -->
{#if mood.isCustom}
<div class="absolute right-2 top-2">
<span class="rounded-full bg-primary/80 px-2 py-0.5 text-xs font-medium text-white">
Custom
</span>
</div>
{/if}
</button>
<style>
@keyframes pulse-slow {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.85;
transform: scale(1.01);
}
}
@keyframes wave {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes candle {
0%,
100% {
opacity: 1;
filter: brightness(1);
}
25% {
opacity: 0.9;
filter: brightness(0.95);
}
50% {
opacity: 0.85;
filter: brightness(1.05);
}
75% {
opacity: 0.95;
filter: brightness(0.9);
}
}
@keyframes disco {
0%,
100% {
filter: hue-rotate(0deg);
}
50% {
filter: hue-rotate(180deg);
}
}
@keyframes thunder {
0%,
95%,
100% {
opacity: 1;
}
97% {
opacity: 1;
filter: brightness(3);
}
}
.animate-pulse-slow {
animation: pulse-slow 4s ease-in-out infinite;
}
.animate-wave {
animation: wave 3s ease-in-out infinite;
}
.animate-candle {
animation: candle 0.8s ease-in-out infinite;
}
.animate-disco {
animation: disco 2s linear infinite;
}
.animate-thunder {
animation: thunder 5s ease-in-out infinite;
}
</style>

View file

@ -0,0 +1,583 @@
<script lang="ts">
import type { Mood } from '$lib/modules/moodlit/types';
import { getMoodGradient } from '$lib/modules/moodlit/default-moods';
import { X, Pause, Play, Heart, Timer } from '@manacore/shared-icons';
interface Props {
mood: Mood;
isFavorite?: boolean;
onClose: () => void;
onFavoriteToggle?: () => void;
}
let { mood, isFavorite = false, onClose, onFavoriteToggle }: Props = $props();
let isPlaying = $state(true);
let showControls = $state(true);
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
let timerActive = $state(false);
let timerMinutes = $state(5);
let timerRemaining = $state(0);
let timerInterval: ReturnType<typeof setInterval> | null = null;
const gradient = $derived(getMoodGradient(mood));
const animationClass = $derived(getAnimationClass(mood.animationType));
function getAnimationClass(type: string): string {
switch (type) {
case 'pulse':
case 'breath':
return 'animate-breath';
case 'wave':
return 'animate-wave';
case 'candle':
case 'fire':
return 'animate-candle';
case 'disco':
case 'rave':
return 'animate-disco';
case 'thunder':
return 'animate-thunder';
case 'police':
return 'animate-police';
case 'warning':
return 'animate-warning';
case 'flash':
return 'animate-flash';
case 'sos':
return 'animate-sos';
case 'scanner':
return 'animate-scanner';
case 'matrix':
return 'animate-matrix';
case 'sunrise':
return 'animate-sunrise';
case 'sunset':
return 'animate-sunset';
default:
return 'animate-gradient';
}
}
function showControlsTemporarily() {
showControls = true;
if (controlsTimeout) {
clearTimeout(controlsTimeout);
}
controlsTimeout = setTimeout(() => {
if (isPlaying) {
showControls = false;
}
}, 3000);
}
function togglePlay() {
isPlaying = !isPlaying;
if (isPlaying) {
showControlsTemporarily();
} else {
showControls = true;
}
}
function startTimer() {
timerActive = true;
timerRemaining = timerMinutes * 60;
timerInterval = setInterval(() => {
timerRemaining--;
if (timerRemaining <= 0) {
stopTimer();
onClose();
}
}, 1000);
}
function stopTimer() {
timerActive = false;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
} else if (e.key === ' ') {
e.preventDefault();
togglePlay();
}
}
$effect(() => {
showControlsTemporarily();
return () => {
if (controlsTimeout) clearTimeout(controlsTimeout);
if (timerInterval) clearInterval(timerInterval);
};
});
</script>
<svelte:window on:keydown={handleKeydown} />
<div
class="fixed inset-0 z-50 flex items-center justify-center cursor-pointer select-none"
onclick={showControlsTemporarily}
onmousemove={showControlsTemporarily}
role="presentation"
>
<!-- Animated Background -->
<div
class="absolute inset-0 {animationClass}"
class:paused={!isPlaying}
style="background: {gradient}; background-size: 400% 400%;"
></div>
<!-- Particle Effects for certain animations -->
{#if mood.animationType === 'sparkle' || mood.animationType === 'matrix'}
<div class="particles absolute inset-0 pointer-events-none overflow-hidden">
{#each Array(20) as _, i}
<div
class="particle absolute w-1 h-1 bg-white/60 rounded-full"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
5}s; animation-duration: {3 + Math.random() * 2}s;"
></div>
{/each}
</div>
{/if}
<!-- Controls Overlay -->
<div
class="absolute inset-0 flex flex-col transition-opacity duration-300 pointer-events-none"
class:opacity-0={!showControls}
class:opacity-100={showControls}
>
<!-- Top Bar -->
<div
class="flex items-center justify-between p-4 bg-gradient-to-b from-black/40 to-transparent pointer-events-auto"
>
<div class="flex items-center gap-3">
<button
type="button"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm transition-colors"
onclick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label="Close"
>
<X size={24} class="text-white" />
</button>
<div>
<h1 class="text-xl font-bold text-white drop-shadow-lg">{mood.name}</h1>
<p class="text-sm text-white/70 capitalize">{mood.animationType}</p>
</div>
</div>
<div class="flex items-center gap-2">
{#if timerActive}
<div class="px-3 py-1.5 rounded-full bg-white/20 backdrop-blur-sm text-white font-mono">
{formatTime(timerRemaining)}
</div>
{/if}
<button
type="button"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm transition-colors"
onclick={(e) => {
e.stopPropagation();
onFavoriteToggle?.();
}}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={20}
weight={isFavorite ? 'fill' : 'regular'}
class={isFavorite ? 'text-red-500' : 'text-white'}
/>
</button>
</div>
</div>
<!-- Center Play/Pause -->
<div class="flex-1 flex items-center justify-center pointer-events-auto">
<button
type="button"
class="p-6 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm transition-all hover:scale-110"
onclick={(e) => {
e.stopPropagation();
togglePlay();
}}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{#if isPlaying}
<Pause size={48} class="text-white" />
{:else}
<Play size={48} class="text-white" />
{/if}
</button>
</div>
<!-- Bottom Bar -->
<div class="p-4 bg-gradient-to-t from-black/40 to-transparent pointer-events-auto">
<div class="flex items-center justify-center gap-4">
{#if !timerActive}
<div class="flex items-center gap-2 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2">
<Timer size={20} class="text-white" />
<select
class="bg-transparent text-white border-none outline-none cursor-pointer"
bind:value={timerMinutes}
onclick={(e) => e.stopPropagation()}
>
<option value={1}>1 min</option>
<option value={5}>5 min</option>
<option value={10}>10 min</option>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
</select>
<button
type="button"
class="px-3 py-1 bg-white/20 hover:bg-white/30 rounded-full text-sm text-white transition-colors"
onclick={(e) => {
e.stopPropagation();
startTimer();
}}
>
Start Timer
</button>
</div>
{:else}
<button
type="button"
class="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-full text-white transition-colors"
onclick={(e) => {
e.stopPropagation();
stopTimer();
}}
>
Stop Timer
</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.animate-gradient {
animation: gradient-shift 8s ease infinite;
}
.animate-breath {
animation: breath 4s ease-in-out infinite;
}
.animate-wave {
animation: wave 3s ease-in-out infinite;
}
.animate-candle {
animation: candle 0.5s ease-in-out infinite;
}
.animate-disco {
animation: disco 0.5s linear infinite;
}
.animate-thunder {
animation: thunder 5s ease-in-out infinite;
}
.animate-police {
animation: police 0.5s linear infinite;
}
.animate-warning {
animation: warning 0.8s ease-in-out infinite;
}
.animate-flash {
animation: flash 0.2s linear infinite;
}
.animate-sos {
animation: sos 2.5s linear infinite;
}
.animate-scanner {
animation: scanner 2s ease-in-out infinite;
}
.animate-matrix {
animation: matrix 0.1s steps(2) infinite;
}
.animate-sunrise {
animation: sunrise 30s ease-in-out infinite;
}
.animate-sunset {
animation: sunset 30s ease-in-out infinite;
}
.paused {
animation-play-state: paused !important;
}
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes breath {
0%,
100% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.02);
}
}
@keyframes wave {
0%,
100% {
background-position: 0% 50%;
opacity: 1;
}
50% {
background-position: 100% 50%;
opacity: 0.85;
}
}
@keyframes candle {
0%,
100% {
opacity: 1;
filter: brightness(1);
}
25% {
opacity: 0.9;
filter: brightness(0.95);
}
50% {
opacity: 0.85;
filter: brightness(1.1);
}
75% {
opacity: 0.95;
filter: brightness(0.92);
}
}
@keyframes disco {
0% {
filter: hue-rotate(0deg) saturate(1.2);
}
100% {
filter: hue-rotate(360deg) saturate(1.2);
}
}
@keyframes thunder {
0%,
94%,
100% {
opacity: 1;
filter: brightness(1);
}
95%,
97% {
opacity: 1;
filter: brightness(3);
}
}
@keyframes police {
0%,
49% {
filter: hue-rotate(0deg);
}
50%,
100% {
filter: hue-rotate(180deg);
}
}
@keyframes warning {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@keyframes flash {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@keyframes sos {
0%,
5% {
opacity: 1;
}
5.1%,
10% {
opacity: 0;
}
10.1%,
15% {
opacity: 1;
}
15.1%,
20% {
opacity: 0;
}
20.1%,
25% {
opacity: 1;
}
25.1%,
35% {
opacity: 0;
}
35.1%,
45% {
opacity: 1;
}
45.1%,
50% {
opacity: 0;
}
50.1%,
60% {
opacity: 1;
}
60.1%,
65% {
opacity: 0;
}
65.1%,
75% {
opacity: 1;
}
75.1%,
80% {
opacity: 0;
}
80.1%,
82% {
opacity: 1;
}
82.1%,
85% {
opacity: 0;
}
85.1%,
87% {
opacity: 1;
}
87.1%,
90% {
opacity: 0;
}
90.1%,
92% {
opacity: 1;
}
92.1%,
100% {
opacity: 0;
}
}
@keyframes scanner {
0%,
100% {
filter: brightness(0.8);
}
50% {
filter: brightness(1.5);
}
}
@keyframes matrix {
0% {
filter: brightness(1) contrast(1.1);
}
50% {
filter: brightness(0.8) contrast(1.2);
}
}
@keyframes sunrise {
0% {
filter: brightness(0.3) saturate(0.5);
}
50% {
filter: brightness(1) saturate(1);
}
100% {
filter: brightness(1.2) saturate(1.2);
}
}
@keyframes sunset {
0% {
filter: brightness(1.2) saturate(1.2);
}
50% {
filter: brightness(0.8) saturate(1.5);
}
100% {
filter: brightness(0.3) saturate(0.5);
}
}
.particle {
animation: float-up linear infinite;
}
@keyframes float-up {
0% {
transform: translateY(100vh) scale(0);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-10vh) scale(1);
opacity: 0;
}
}
</style>

View file

@ -0,0 +1,198 @@
/**
* 24 preset moods matching the mobile app.
*/
import type { Mood } from './types';
export const DEFAULT_MOODS: Mood[] = [
{
id: 'fire',
name: 'Fire',
colors: ['#ff6b35', '#ff4500', '#dc143c', '#8b0000'],
animationType: 'candle',
order: 0,
},
{
id: 'breath',
name: 'Breath',
colors: ['#667eea', '#764ba2', '#f093fb'],
animationType: 'breath',
order: 1,
},
{
id: 'northern-lights',
name: 'Northern Lights',
colors: ['#5f27cd', '#341f97', '#8854d0', '#a29bfe'],
animationType: 'wave',
order: 2,
},
{
id: 'thunder',
name: 'Thunder',
colors: ['#2c3e50', '#34495e', '#ffffff', '#95a5a6'],
animationType: 'thunder',
order: 3,
},
{
id: 'light',
name: 'Light',
colors: ['#ffffff', '#f8f9fa', '#e9ecef'],
animationType: 'gradient',
order: 4,
},
{
id: 'flash',
name: 'Flash',
colors: ['#ffffff'],
animationType: 'flash',
order: 5,
},
{
id: 'sos',
name: 'SOS',
colors: ['#ffffff'],
animationType: 'sos',
order: 6,
},
{
id: 'ocean',
name: 'Ocean',
colors: ['#48dbfb', '#0abde3', '#10ac84', '#1dd1a1'],
animationType: 'wave',
order: 7,
},
{
id: 'candle',
name: 'Candle',
colors: ['#ff9f43', '#ee5a24', '#ffeaa7'],
animationType: 'candle',
order: 8,
},
{
id: 'police',
name: 'Police',
colors: ['#e74c3c', '#3498db'],
animationType: 'police',
order: 9,
},
{
id: 'warning',
name: 'Warning',
colors: ['#f39c12', '#e67e22'],
animationType: 'warning',
order: 10,
},
{
id: 'disco',
name: 'Disco',
colors: ['#e74c3c', '#9b59b6', '#3498db', '#1abc9c', '#f1c40f', '#e67e22'],
animationType: 'disco',
order: 11,
},
{
id: 'sunrise',
name: 'Sunrise',
colors: ['#1a1a2e', '#16213e', '#e94560', '#ff6b6b', '#feca57', '#fffacd'],
animationType: 'sunrise',
order: 12,
},
{
id: 'sunset',
name: 'Sunset',
colors: ['#ff6b6b', '#feca57', '#ff9ff3', '#a29bfe', '#341f97', '#1a1a2e'],
animationType: 'sunset',
order: 13,
},
{
id: 'forest',
name: 'Forest',
colors: ['#27ae60', '#2ecc71', '#1abc9c', '#16a085'],
animationType: 'pulse',
order: 14,
},
{
id: 'rave',
name: 'Rave',
colors: [
'#ff0000',
'#ff00ff',
'#00ffff',
'#00ff00',
'#ffff00',
'#ff6600',
'#0066ff',
'#ff0066',
],
animationType: 'rave',
order: 15,
},
{
id: 'scanner',
name: 'Scanner',
colors: ['#e74c3c'],
animationType: 'scanner',
order: 16,
},
{
id: 'matrix',
name: 'Matrix',
colors: ['#00ff00'],
animationType: 'matrix',
order: 17,
},
{
id: 'lavender',
name: 'Lavender',
colors: ['#e6e6fa', '#dda0dd', '#da70d6', '#ba55d3'],
animationType: 'pulse',
order: 18,
},
{
id: 'cherry-blossom',
name: 'Cherry Blossom',
colors: ['#ffb7c5', '#ff69b4', '#ff1493', '#db7093'],
animationType: 'wave',
order: 19,
},
{
id: 'autumn',
name: 'Autumn',
colors: ['#d35400', '#e67e22', '#f39c12', '#c0392b'],
animationType: 'gradient',
order: 20,
},
{
id: 'ice',
name: 'Ice',
colors: ['#74b9ff', '#0984e3', '#81ecec', '#00cec9'],
animationType: 'wave',
order: 21,
},
{
id: 'romance',
name: 'Romance',
colors: ['#fd79a8', '#e84393', '#d63031', '#ff7675'],
animationType: 'pulse',
order: 22,
},
{
id: 'midnight',
name: 'Midnight',
colors: ['#0c0c0c', '#1a1a2e', '#16213e', '#0f3460'],
animationType: 'breath',
order: 23,
},
];
/** Get mood by ID. */
export function getDefaultMoodById(id: string): Mood | undefined {
return DEFAULT_MOODS.find((m) => m.id === id);
}
/** Get gradient CSS for a mood. */
export function getMoodGradient(mood: Mood): string {
if (mood.colors.length === 1) {
return mood.colors[0];
}
return `linear-gradient(135deg, ${mood.colors.join(', ')})`;
}

View file

@ -0,0 +1,20 @@
/**
* Moodlit module barrel exports.
*/
export { moodsStore } from './stores/moods.svelte';
export { sequencesStore } from './stores/sequences.svelte';
export { useAllMoods, useAllSequences, getMoodGradient, getMoodById } from './queries';
export { moodTable, sequenceTable, MOODLIT_GUEST_SEED } from './collections';
export { DEFAULT_MOODS, getDefaultMoodById } from './default-moods';
export type {
LocalMood,
LocalSequence,
Mood,
MoodSequence,
MoodSequenceItem,
MoodSettings,
AnimationType,
AnimationInfo,
} from './types';
export { ANIMATIONS } from './types';

View file

@ -0,0 +1,40 @@
/**
* Reactive queries for Moodlit uses Dexie liveQuery on the unified DB.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalMood, LocalSequence, Mood } from './types';
// ─── Helpers ──────────────────────────────────────────────
/** Get gradient CSS for a mood. */
export function getMoodGradient(mood: Mood): string {
if (mood.colors.length === 1) {
return mood.colors[0];
}
return `linear-gradient(135deg, ${mood.colors.join(', ')})`;
}
/** Get mood by ID from a list. */
export function getMoodById(moods: Mood[], id: string): Mood | undefined {
return moods.find((m) => m.id === id);
}
// ─── Live Queries ──────────────────────────────────────────
/** All moods, sorted by name. */
export function useAllMoods() {
return liveQuery(async () => {
const locals = await db.table<LocalMood>('moods').toArray();
return locals.filter((m) => !m.deletedAt);
});
}
/** All sequences, sorted by name. */
export function useAllSequences() {
return liveQuery(async () => {
const locals = await db.table<LocalSequence>('sequences').toArray();
return locals.filter((s) => !s.deletedAt);
});
}

View file

@ -0,0 +1,119 @@
/**
* Moods mutation store write operations for the unified DB.
*/
import { db } from '$lib/data/database';
import type { LocalMood } from '../types';
import type { Mood, MoodSettings } from '../types';
// Default settings
const DEFAULT_SETTINGS: MoodSettings = {
animationSpeed: 'normal',
brightness: 100,
autoTimer: 0,
autoMoodSwitch: false,
autoMoodSwitchInterval: 5,
};
function createMoodsStore() {
let customMoods = $state<Mood[]>([]);
let favoriteIds = $state<string[]>([]);
let settings = $state<MoodSettings>({ ...DEFAULT_SETTINGS });
let activeMood = $state<Mood | null>(null);
// Load from localStorage on init
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('moodlit-store');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.customMoods) customMoods = parsed.customMoods;
if (parsed.favoriteIds) favoriteIds = parsed.favoriteIds;
if (parsed.settings) settings = { ...DEFAULT_SETTINGS, ...parsed.settings };
} catch (e) {
console.error('Failed to load moods from localStorage', e);
}
}
}
function persist() {
if (typeof window !== 'undefined') {
localStorage.setItem('moodlit-store', JSON.stringify({ customMoods, favoriteIds, settings }));
}
}
return {
get customMoods() {
return customMoods;
},
get favoriteIds() {
return favoriteIds;
},
get settings() {
return settings;
},
get activeMood() {
return activeMood;
},
isFavorite(moodId: string): boolean {
return favoriteIds.includes(moodId);
},
setActiveMood(mood: Mood | null) {
activeMood = mood;
},
addMood(mood: Mood) {
customMoods = [...customMoods, mood];
persist();
},
updateMood(id: string, updates: Partial<Mood>) {
customMoods = customMoods.map((m) => (m.id === id ? { ...m, ...updates } : m));
persist();
},
removeMood(id: string) {
customMoods = customMoods.filter((m) => m.id !== id);
favoriteIds = favoriteIds.filter((fid) => fid !== id);
persist();
},
toggleFavorite(moodId: string) {
if (favoriteIds.includes(moodId)) {
favoriteIds = favoriteIds.filter((id) => id !== moodId);
} else {
favoriteIds = [...favoriteIds, moodId];
}
persist();
},
updateSettings(updates: Partial<MoodSettings>) {
settings = { ...settings, ...updates };
persist();
},
// IndexedDB mutation methods
async createMood(data: { name: string; colors: string[]; animation: string }) {
await db.table<LocalMood>('moods').add({
id: crypto.randomUUID(),
name: data.name,
colors: data.colors,
animation: data.animation,
isDefault: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async deleteMood(id: string) {
await db.table('moods').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};
}
export const moodsStore = createMoodsStore();

View file

@ -0,0 +1,151 @@
/**
* Sequences mutation store write operations for the unified DB.
*/
import { db } from '$lib/data/database';
import type { LocalSequence } from '../types';
import type { MoodSequence } from '../types';
// Default sequences for demo purposes
const DEFAULT_SEQUENCES: MoodSequence[] = [
{
id: 'relaxation',
name: 'Relaxation',
items: [
{ moodId: 'breath', duration: 60 },
{ moodId: 'ocean', duration: 60 },
{ moodId: 'lavender', duration: 60 },
],
transitionDuration: 5,
},
{
id: 'focus',
name: 'Focus Flow',
items: [
{ moodId: 'forest', duration: 120 },
{ moodId: 'northern-lights', duration: 120 },
],
transitionDuration: 10,
},
{
id: 'party',
name: 'Party Mode',
items: [
{ moodId: 'disco', duration: 30 },
{ moodId: 'rave', duration: 30 },
{ moodId: 'police', duration: 15 },
],
transitionDuration: 2,
},
];
function createSequencesStore() {
let sequences = $state<MoodSequence[]>([...DEFAULT_SEQUENCES]);
let customSequences = $state<MoodSequence[]>([]);
let activeSequence = $state<MoodSequence | null>(null);
let currentItemIndex = $state(0);
let isPlaying = $state(false);
// Load from localStorage on init
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('moodlit-sequences');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.customSequences) customSequences = parsed.customSequences;
} catch (e) {
console.error('Failed to load sequences from localStorage', e);
}
}
}
function persist() {
if (typeof window !== 'undefined') {
localStorage.setItem('moodlit-sequences', JSON.stringify({ customSequences }));
}
}
return {
get sequences() {
return [...sequences, ...customSequences];
},
get customSequences() {
return customSequences;
},
get activeSequence() {
return activeSequence;
},
get currentItemIndex() {
return currentItemIndex;
},
get isPlaying() {
return isPlaying;
},
addSequence(sequence: MoodSequence) {
customSequences = [...customSequences, { ...sequence, isCustom: true }];
persist();
},
updateSequence(id: string, updates: Partial<MoodSequence>) {
customSequences = customSequences.map((s) => (s.id === id ? { ...s, ...updates } : s));
persist();
},
removeSequence(id: string) {
customSequences = customSequences.filter((s) => s.id !== id);
persist();
},
playSequence(sequence: MoodSequence) {
activeSequence = sequence;
currentItemIndex = 0;
isPlaying = true;
},
stopSequence() {
activeSequence = null;
currentItemIndex = 0;
isPlaying = false;
},
nextItem() {
if (activeSequence && currentItemIndex < activeSequence.items.length - 1) {
currentItemIndex++;
} else {
currentItemIndex = 0;
}
},
previousItem() {
if (currentItemIndex > 0) {
currentItemIndex--;
}
},
togglePlay() {
isPlaying = !isPlaying;
},
// IndexedDB mutation methods
async createSequence(data: { name: string; moodIds: string[]; duration: number }) {
await db.table<LocalSequence>('sequences').add({
id: crypto.randomUUID(),
name: data.name,
moodIds: data.moodIds,
duration: data.duration,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async deleteSequence(id: string) {
await db.table('sequences').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};
}
export const sequencesStore = createSequencesStore();

View file

@ -0,0 +1,109 @@
/**
* Moodlit module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
// Animation types available for moods
export type AnimationType =
| 'gradient'
| 'pulse'
| 'wave'
| 'flash'
| 'sos'
| 'candle'
| 'police'
| 'warning'
| 'disco'
| 'thunder'
| 'breath'
| 'rave'
| 'scanner'
| 'matrix'
| 'sunrise'
| 'sunset'
| 'aurora'
| 'fire'
| 'ocean'
| 'forest'
| 'sparkle';
export interface LocalMood extends BaseRecord {
name: string;
colors: string[];
animation: string;
isDefault: boolean;
}
export interface LocalSequence extends BaseRecord {
name: string;
moodIds: string[];
duration: number;
}
// Mood interface (UI-facing)
export interface Mood {
id: string;
name: string;
colors: string[];
animationType: AnimationType;
isCustom?: boolean;
order?: number;
createdAt?: string;
}
// Sequence item (mood with duration)
export interface MoodSequenceItem {
moodId: string;
duration: number; // seconds
}
// Mood sequence
export interface MoodSequence {
id: string;
name: string;
items: MoodSequenceItem[];
transitionDuration: number; // 2, 5, or 10 seconds
isCustom?: boolean;
}
// Settings
export interface MoodSettings {
animationSpeed: 'slow' | 'normal' | 'fast';
brightness: number; // 0-100
autoTimer: number; // 0 = off, else minutes
autoMoodSwitch: boolean;
autoMoodSwitchInterval: number; // minutes
}
// Animation metadata for UI
export interface AnimationInfo {
id: AnimationType;
name: string;
description: string;
}
// Available animations with descriptions
export const ANIMATIONS: AnimationInfo[] = [
{ id: 'gradient', name: 'Gradient', description: 'Smooth color gradient' },
{ id: 'pulse', name: 'Pulse', description: 'Breathing opacity effect' },
{ id: 'wave', name: 'Wave', description: 'Smooth wave oscillation' },
{ id: 'breath', name: 'Breath', description: '4-second breathing cycle' },
{ id: 'aurora', name: 'Aurora', description: 'Northern lights effect' },
{ id: 'fire', name: 'Fire', description: 'Warm flickering flames' },
{ id: 'candle', name: 'Candle', description: 'Soft candlelight flicker' },
{ id: 'ocean', name: 'Ocean', description: 'Calm ocean waves' },
{ id: 'forest', name: 'Forest', description: 'Peaceful forest ambience' },
{ id: 'thunder', name: 'Thunder', description: 'Random lightning flashes' },
{ id: 'sparkle', name: 'Sparkle', description: 'Twinkling star effect' },
{ id: 'sunrise', name: 'Sunrise', description: 'Slow warming colors' },
{ id: 'sunset', name: 'Sunset', description: 'Evening color transition' },
{ id: 'disco', name: 'Disco', description: 'Fast color cycling' },
{ id: 'rave', name: 'Rave', description: 'Very fast chaotic colors' },
{ id: 'scanner', name: 'Scanner', description: 'Light wave sweep' },
{ id: 'matrix', name: 'Matrix', description: 'Digital green blinking' },
{ id: 'flash', name: 'Flash', description: 'Quick white flashes' },
{ id: 'sos', name: 'SOS', description: 'Morse code pattern' },
{ id: 'police', name: 'Police', description: 'Red/blue alternating' },
{ id: 'warning', name: 'Warning', description: 'Blinking orange/yellow' },
];

View file

@ -0,0 +1,113 @@
/**
* SkillTree module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalSkill, LocalActivity, LocalAchievement } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const skillTable = db.table<LocalSkill>('skills');
export const activityTable = db.table<LocalActivity>('activities');
export const achievementTable = db.table<LocalAchievement>('achievements');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_CODING_ID = 'demo-coding';
const DEMO_FITNESS_ID = 'demo-fitness';
const DEMO_CREATIVE_ID = 'demo-creative';
export const SKILLTREE_GUEST_SEED = {
skills: [
{
id: DEMO_CODING_ID,
name: 'Programmieren',
description: 'Software-Entwicklung und Coding-Skills',
branch: 'intellect' as const,
icon: '💻',
currentXp: 250,
totalXp: 250,
level: 1,
},
{
id: DEMO_FITNESS_ID,
name: 'Fitness',
description: 'Körperliche Fitness und Training',
branch: 'body' as const,
icon: '💪',
currentXp: 120,
totalXp: 120,
level: 1,
},
{
id: DEMO_CREATIVE_ID,
name: 'Zeichnen',
description: 'Illustration, Skizzen und visuelles Denken',
branch: 'creativity' as const,
icon: '🎨',
currentXp: 60,
totalXp: 60,
level: 0,
},
],
activities: [
{
id: 'activity-1',
skillId: DEMO_CODING_ID,
xpEarned: 100,
description: 'TypeScript-Projekt aufgesetzt',
duration: 60,
timestamp: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-2',
skillId: DEMO_FITNESS_ID,
xpEarned: 50,
description: '5 km Joggen im Park',
duration: 35,
timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-3',
skillId: DEMO_CODING_ID,
xpEarned: 100,
description: 'REST API mit Hono gebaut',
duration: 90,
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-4',
skillId: DEMO_CREATIVE_ID,
xpEarned: 60,
description: 'Erste Skizzen mit Procreate',
duration: 45,
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-5',
skillId: DEMO_FITNESS_ID,
xpEarned: 70,
description: 'Krafttraining — Oberkörper',
duration: 50,
timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-6',
skillId: DEMO_CODING_ID,
xpEarned: 50,
description: 'Unit Tests geschrieben',
duration: 30,
timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(),
},
],
achievements: [
{
id: 'achievement-1',
key: 'first-skill',
name: 'Erste Schritte',
description: 'Deinen ersten Skill erstellt',
icon: '🌱',
unlockedAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
},
],
};

View file

@ -0,0 +1,93 @@
<script lang="ts">
import type { AchievementWithStatus } from '../types';
import { RARITY_INFO } from '../types';
import { Trophy, Lock, Star } from '@manacore/shared-icons';
interface Props {
achievement: AchievementWithStatus;
}
let { achievement }: Props = $props();
const rarity = $derived(RARITY_INFO[achievement.rarity]);
const progressPercent = $derived(
achievement.unlocked
? 100
: Math.round((achievement.progress / achievement.condition.threshold) * 100)
);
</script>
<div
class="relative rounded-xl border p-4 transition-all duration-200 {achievement.unlocked
? `${rarity.bgColor} ${rarity.borderColor}`
: 'border-gray-700/50 bg-gray-800/30'} {achievement.unlocked
? 'hover:-translate-y-0.5 hover:shadow-lg'
: 'opacity-70'}"
>
<!-- Rarity indicator -->
<div class="absolute right-3 top-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {rarity.color} {rarity.bgColor}">
{rarity.name}
</span>
</div>
<div class="flex items-start gap-3">
<!-- Icon -->
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full {achievement.unlocked
? 'bg-yellow-500/20'
: 'bg-gray-700/50'}"
>
{#if achievement.unlocked}
<Trophy class="h-6 w-6 text-yellow-400" />
{:else}
<Lock class="h-6 w-6 text-gray-500" />
{/if}
</div>
<div class="min-w-0 flex-1">
<!-- Name -->
<h3 class="font-semibold {achievement.unlocked ? 'text-white' : 'text-gray-400'}">
{achievement.name}
</h3>
<!-- Description -->
<p class="mt-0.5 text-sm {achievement.unlocked ? 'text-gray-300' : 'text-gray-500'}">
{achievement.description}
</p>
<!-- Progress bar (if not unlocked) -->
{#if !achievement.unlocked}
<div class="mt-2">
<div class="flex items-center justify-between text-xs text-gray-500">
<span>{achievement.progress} / {achievement.condition.threshold}</span>
<span>{progressPercent}%</span>
</div>
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-gray-700">
<div
class="h-full rounded-full bg-gradient-to-r from-gray-500 to-gray-400 transition-all duration-300"
style="width: {progressPercent}%"
></div>
</div>
</div>
{/if}
<!-- XP reward + unlock date -->
<div class="mt-2 flex items-center gap-3 text-xs">
<span
class="flex items-center gap-1 {achievement.unlocked
? 'text-yellow-400'
: 'text-gray-500'}"
>
<Star class="h-3 w-3" />
+{achievement.xpReward} XP
</span>
{#if achievement.unlocked && achievement.unlockedAt}
<span class="text-gray-500">
{new Date(achievement.unlockedAt).toLocaleDateString('de-DE')}
</span>
{/if}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,151 @@
<script lang="ts">
import type { AchievementUnlockResult } from '../types';
import { RARITY_INFO } from '../types';
import { Trophy, Sparkle, Star } from '@manacore/shared-icons';
import { onMount } from 'svelte';
interface Props {
result: AchievementUnlockResult;
onClose: () => void;
}
let { result, onClose }: Props = $props();
const rarity = RARITY_INFO[result.achievement.rarity];
function getRarityGradient(r: string): string {
const gradients: Record<string, string> = {
common: 'from-gray-500 to-gray-600',
uncommon: 'from-green-500 to-green-600',
rare: 'from-blue-500 to-blue-600',
epic: 'from-purple-500 to-purple-600',
legendary: 'from-yellow-400 to-yellow-500',
};
return gradients[r] ?? gradients.common;
}
// Auto-close after 3.5 seconds
onMount(() => {
const timer = setTimeout(onClose, 3500);
return () => clearTimeout(timer);
});
</script>
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onclick={onClose}
role="dialog"
aria-modal="true"
>
<div class="celebration-container text-center">
<!-- Sparkle effects -->
<div class="sparkles">
{#each Array(10) as _, i}
<div class="sparkle" style="--delay: {i * 0.08}s; --angle: {i * 36}deg">
<Sparkle class="h-5 w-5 text-yellow-400" />
</div>
{/each}
</div>
<!-- Main content -->
<div class="relative z-10">
<!-- Trophy icon -->
<div
class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br {getRarityGradient(
result.achievement.rarity
)} achievement-bounce shadow-lg shadow-yellow-500/20"
>
<Trophy class="h-10 w-10 text-white" />
</div>
<!-- Achievement unlocked text -->
<h2 class="mb-1 text-2xl font-bold text-yellow-400 achievement-text">
Achievement freigeschaltet!
</h2>
<!-- Achievement name -->
<p class="mb-2 text-xl font-semibold text-white">{result.achievement.name}</p>
<!-- Description -->
<p class="mb-4 text-gray-400">{result.achievement.description}</p>
<!-- Rarity + XP reward -->
<div class="inline-flex items-center gap-3">
<span class="rounded-full px-3 py-1 text-sm font-medium {rarity.color} {rarity.bgColor}">
{rarity.name}
</span>
<span class="flex items-center gap-1 text-yellow-400">
<Star class="h-4 w-4" />
+{result.xpReward} XP
</span>
</div>
<!-- Click to close -->
<p class="mt-6 text-sm text-gray-500">Klicken zum Schließen</p>
</div>
</div>
</div>
<style>
.celebration-container {
position: relative;
padding: 2rem;
}
.sparkles {
position: absolute;
inset: 0;
pointer-events: none;
}
.sparkle {
position: absolute;
top: 50%;
left: 50%;
animation: sparkle-fly 0.8s ease-out forwards;
animation-delay: var(--delay);
opacity: 0;
}
@keyframes sparkle-fly {
0% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-120px);
opacity: 0;
}
}
.achievement-bounce {
animation: ach-bounce 0.5s ease-out;
}
@keyframes ach-bounce {
0% {
transform: scale(0);
}
60% {
transform: scale(1.15);
}
100% {
transform: scale(1);
}
}
.achievement-text {
animation: ach-glow 1s ease-in-out infinite alternate;
}
@keyframes ach-glow {
from {
text-shadow: 0 0 8px rgba(251, 191, 36, 0.4);
}
to {
text-shadow:
0 0 20px rgba(251, 191, 36, 0.6),
0 0 40px rgba(251, 191, 36, 0.3);
}
}
</style>

View file

@ -0,0 +1,126 @@
<script lang="ts">
import type { Skill, SkillBranch } from '../types';
import { BRANCH_INFO } from '../types';
import { X } from '@manacore/shared-icons';
interface Props {
onClose: () => void;
onSave: (skill: Partial<Skill>) => Promise<void>;
}
let { onClose, onSave }: Props = $props();
let name = $state('');
let description = $state('');
let branch = $state<SkillBranch>('intellect');
let saving = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!name.trim()) return;
saving = true;
try {
await onSave({
name: name.trim(),
description: description.trim(),
branch,
});
} finally {
saving = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">Neuer Skill</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X class="h-5 w-5" />
</button>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<!-- Name -->
<div>
<label for="name" class="mb-1 block text-sm font-medium text-gray-300"> Name * </label>
<input
id="name"
type="text"
bind:value={name}
placeholder="z.B. TypeScript"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
required
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
Beschreibung
</label>
<textarea
id="description"
bind:value={description}
placeholder="Worum geht es bei diesem Skill?"
rows="3"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
></textarea>
</div>
<!-- Branch -->
<div>
<label for="branch" class="mb-2 block text-sm font-medium text-gray-300"> Kategorie </label>
<div class="grid grid-cols-2 gap-2">
{#each Object.entries(BRANCH_INFO) as [key, info]}
<button
type="button"
onclick={() => (branch = key as SkillBranch)}
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {branch ===
key
? 'border-emerald-500 bg-emerald-500/20 text-white'
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
>
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
{info.name}
</button>
{/each}
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-4">
<button
type="button"
onclick={onClose}
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
>
Abbrechen
</button>
<button
type="submit"
disabled={!name.trim() || saving}
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,171 @@
<script lang="ts">
import type { Skill } from '../types';
import { LEVEL_NAMES } from '../types';
import { X, Lightning, Clock, Star } from '@manacore/shared-icons';
interface Props {
skill: Skill;
onClose: () => void;
onSave: (xp: number, description: string, duration?: number) => Promise<void>;
}
let { skill, onClose, onSave }: Props = $props();
let xp = $state(10);
let description = $state('');
let duration = $state<number | undefined>(undefined);
let saving = $state(false);
// Quick XP presets
const xpPresets = [
{ label: '+5', value: 5, desc: 'Kurz geübt' },
{ label: '+10', value: 10, desc: 'Normale Session' },
{ label: '+25', value: 25, desc: 'Intensive Session' },
{ label: '+50', value: 50, desc: 'Großer Fortschritt' },
{ label: '+100', value: 100, desc: 'Meilenstein erreicht' },
];
async function handleSubmit(e: Event) {
e.preventDefault();
if (xp <= 0) return;
saving = true;
try {
await onSave(xp, description || `+${xp} XP`, duration);
} finally {
saving = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function selectPreset(preset: { value: number; desc: string }) {
xp = preset.value;
if (!description) {
description = preset.desc;
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-white">XP hinzufügen</h2>
<p class="text-sm text-gray-400">{skill.name} (Lvl {skill.level})</p>
</div>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X class="h-5 w-5" />
</button>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<!-- Quick XP Presets -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-300"> Schnellauswahl </label>
<div class="flex flex-wrap gap-2">
{#each xpPresets as preset}
<button
type="button"
onclick={() => selectPreset(preset)}
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors {xp ===
preset.value
? 'border-emerald-500 bg-emerald-500/20 text-emerald-400'
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
>
{preset.label}
</button>
{/each}
</div>
</div>
<!-- Custom XP -->
<div>
<label for="xp" class="mb-1 block text-sm font-medium text-gray-300">
<Lightning class="mr-1 inline h-4 w-4 text-yellow-500" />
XP Menge
</label>
<input
id="xp"
type="number"
bind:value={xp}
min="1"
max="1000"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
Was hast du gemacht?
</label>
<input
id="description"
type="text"
bind:value={description}
placeholder="z.B. Tutorial abgeschlossen"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<!-- Duration (optional) -->
<div>
<label for="duration" class="mb-1 block text-sm font-medium text-gray-300">
<Clock class="mr-1 inline h-4 w-4 text-gray-400" />
Dauer (optional, Minuten)
</label>
<input
id="duration"
type="number"
bind:value={duration}
min="1"
placeholder="z.B. 30"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<!-- Preview -->
<div class="rounded-lg bg-gray-700/50 p-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">Vorschau</span>
<span class="font-medium text-emerald-400">+{xp} XP</span>
</div>
<div class="mt-1 text-xs text-gray-500">
Neuer Stand: {(skill.totalXp + xp).toLocaleString()} XP
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-2">
<button
type="button"
onclick={onClose}
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
>
Abbrechen
</button>
<button
type="submit"
disabled={xp <= 0 || saving}
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Speichern...' : 'XP vergeben'}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,192 @@
<script lang="ts">
import type { Skill, SkillBranch } from '../types';
import { BRANCH_INFO } from '../types';
import { X, Trash } from '@manacore/shared-icons';
interface Props {
skill: Skill;
onClose: () => void;
onSave: (updates: Partial<Skill>) => Promise<void>;
onDelete: () => void;
}
let { skill, onClose, onSave, onDelete }: Props = $props();
let name = $state(skill.name);
let description = $state(skill.description);
let branch = $state<SkillBranch>(skill.branch);
let saving = $state(false);
let showDeleteConfirm = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!name.trim()) return;
saving = true;
try {
await onSave({
name: name.trim(),
description: description.trim(),
branch,
});
onClose();
} finally {
saving = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function confirmDelete() {
onDelete();
onClose();
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">Skill bearbeiten</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X class="h-5 w-5" />
</button>
</div>
{#if showDeleteConfirm}
<!-- Delete Confirmation -->
<div class="text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-500/20"
>
<Trash class="h-8 w-8 text-red-500" />
</div>
<h3 class="mb-2 text-lg font-semibold text-white">Skill löschen?</h3>
<p class="mb-6 text-gray-400">
"{skill.name}" und alle zugehörigen Aktivitäten werden unwiderruflich gelöscht.
</p>
<div class="flex gap-3">
<button
onclick={() => (showDeleteConfirm = false)}
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={confirmDelete}
class="flex-1 rounded-lg bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-500"
>
Löschen
</button>
</div>
</div>
{:else}
<form onsubmit={handleSubmit} class="space-y-4">
<!-- Name -->
<div>
<label for="name" class="mb-1 block text-sm font-medium text-gray-300"> Name * </label>
<input
id="name"
type="text"
bind:value={name}
placeholder="z.B. TypeScript"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
required
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
Beschreibung
</label>
<textarea
id="description"
bind:value={description}
placeholder="Worum geht es bei diesem Skill?"
rows="3"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
></textarea>
</div>
<!-- Branch -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-300"> Kategorie </label>
<div class="grid grid-cols-2 gap-2">
{#each Object.entries(BRANCH_INFO) as [key, info]}
<button
type="button"
onclick={() => (branch = key as SkillBranch)}
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {branch ===
key
? 'border-emerald-500 bg-emerald-500/20 text-white'
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
>
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
{info.name}
</button>
{/each}
</div>
</div>
<!-- Stats (read-only) -->
<div class="rounded-lg bg-gray-700/50 p-3">
<div class="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div class="text-gray-400">Level</div>
<div class="font-semibold text-white">{skill.level}</div>
</div>
<div>
<div class="text-gray-400">Total XP</div>
<div class="font-semibold text-white">{skill.totalXp.toLocaleString()}</div>
</div>
<div>
<div class="text-gray-400">Erstellt</div>
<div class="font-semibold text-white">
{new Date(skill.createdAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-4">
<button
type="button"
onclick={() => (showDeleteConfirm = true)}
class="rounded-lg bg-red-600/20 p-2 text-red-400 transition-colors hover:bg-red-600/30"
title="Löschen"
>
<Trash class="h-5 w-5" />
</button>
<button
type="button"
onclick={onClose}
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
>
Abbrechen
</button>
<button
type="submit"
disabled={!name.trim() || saving}
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</form>
{/if}
</div>
</div>

View file

@ -0,0 +1,176 @@
<script lang="ts">
import { LEVEL_NAMES } from '../types';
import { Star, Trophy, Sparkle } from '@manacore/shared-icons';
import { onMount } from 'svelte';
interface Props {
skillName: string;
newLevel: number;
onClose: () => void;
}
let { skillName, newLevel, onClose }: Props = $props();
const levelName = LEVEL_NAMES[newLevel] ?? 'Unbekannt';
// Auto-close after 4 seconds
onMount(() => {
const timer = setTimeout(onClose, 4000);
return () => clearTimeout(timer);
});
function getLevelColor(level: number): string {
const colors = [
'from-gray-500 to-gray-600',
'from-blue-500 to-blue-600',
'from-purple-500 to-purple-600',
'from-pink-500 to-pink-600',
'from-orange-500 to-orange-600',
'from-yellow-400 to-yellow-500',
];
return colors[level] ?? colors[0];
}
</script>
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onclick={onClose}
role="dialog"
aria-modal="true"
>
<div class="celebration-container text-center">
<!-- Sparkle effects -->
<div class="sparkles">
{#each Array(12) as _, i}
<div class="sparkle" style="--delay: {i * 0.1}s; --angle: {i * 30}deg">
<Sparkle class="h-6 w-6 text-yellow-400" />
</div>
{/each}
</div>
<!-- Main content -->
<div class="relative z-10">
<!-- Trophy icon -->
<div
class="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br {getLevelColor(
newLevel
)} level-up-bounce shadow-lg shadow-yellow-500/30"
>
<Trophy class="h-12 w-12 text-white" />
</div>
<!-- Level up text -->
<h2 class="mb-2 text-3xl font-bold text-white level-up-text">LEVEL UP!</h2>
<!-- Skill name -->
<p class="mb-4 text-xl text-gray-300">{skillName}</p>
<!-- New level badge -->
<div
class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r {getLevelColor(
newLevel
)} px-6 py-3 text-lg font-bold text-white shadow-lg"
>
<Star class="h-5 w-5 fill-current" />
Level {newLevel} - {levelName}
<Star class="h-5 w-5 fill-current" />
</div>
<!-- Stars -->
<div class="mt-6 flex justify-center gap-2">
{#each Array(newLevel) as _, i}
<Star
class="h-8 w-8 fill-yellow-400 text-yellow-400 star-pop"
style="animation-delay: {0.5 + i * 0.1}s"
/>
{/each}
</div>
<!-- Click to close -->
<p class="mt-6 text-sm text-gray-500">Klicken zum Schließen</p>
</div>
</div>
</div>
<style>
.celebration-container {
position: relative;
padding: 2rem;
}
.sparkles {
position: absolute;
inset: 0;
pointer-events: none;
}
.sparkle {
position: absolute;
top: 50%;
left: 50%;
animation: sparkle-fly 1s ease-out forwards;
animation-delay: var(--delay);
opacity: 0;
}
@keyframes sparkle-fly {
0% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-150px);
opacity: 0;
}
}
.level-up-bounce {
animation: level-bounce 0.6s ease-out;
}
@keyframes level-bounce {
0% {
transform: scale(0);
}
50% {
transform: scale(1.2);
}
70% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
.level-up-text {
animation: text-glow 1s ease-in-out infinite alternate;
}
@keyframes text-glow {
from {
text-shadow: 0 0 10px rgba(251, 191, 36, 0.5);
}
to {
text-shadow:
0 0 30px rgba(251, 191, 36, 0.8),
0 0 60px rgba(251, 191, 36, 0.4);
}
}
:global(.star-pop) {
opacity: 0;
animation: star-pop 0.4s ease-out forwards;
}
@keyframes star-pop {
0% {
transform: scale(0) rotate(-180deg);
opacity: 0;
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,115 @@
<script lang="ts">
import type { Skill } from '../types';
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '../types';
import { Plus, Trash, PencilSimple, Star } from '@manacore/shared-icons';
interface Props {
skill: Skill;
onAddXp: () => void;
onEdit: () => void;
onDelete: () => void;
}
let { skill, onAddXp, onEdit, onDelete }: Props = $props();
const branchInfo = $derived(BRANCH_INFO[skill.branch]);
const levelName = $derived(LEVEL_NAMES[skill.level]);
const progress = $derived(xpProgress(skill.totalXp, skill.level));
const nextLevelXp = $derived(xpForNextLevel(skill.level));
const isMaxLevel = $derived(skill.level >= LEVEL_NAMES.length - 1);
function getLevelColor(level: number): string {
const colors = [
'text-gray-400',
'text-blue-400',
'text-purple-400',
'text-pink-400',
'text-orange-400',
'text-yellow-400',
];
return colors[level] ?? colors[0];
}
</script>
<div
class="skill-card group relative overflow-hidden rounded-xl border border-gray-700 bg-gray-800/50 p-4"
>
<!-- Branch Indicator -->
<div
class="branch-indicator absolute left-0 top-0 h-full"
style="background-color: {branchInfo.color}"
></div>
<!-- Header -->
<div class="mb-3 flex items-start justify-between pl-3">
<div>
<h3 class="text-lg font-semibold text-white">{skill.name}</h3>
<p class="text-sm text-gray-400">{branchInfo.name}</p>
</div>
<div class="flex items-center gap-1">
{#each Array(skill.level) as _, i}
<Star class="h-4 w-4 fill-yellow-500 text-yellow-500" />
{/each}
{#each Array(5 - skill.level) as _, i}
<Star class="h-4 w-4 text-gray-600" />
{/each}
</div>
</div>
<!-- Level Badge -->
<div class="mb-3 pl-3">
<span
class="inline-flex items-center gap-1 rounded-full bg-gray-700/50 px-3 py-1 text-sm {getLevelColor(
skill.level
)}"
>
Lvl {skill.level} - {levelName}
</span>
</div>
<!-- XP Progress -->
<div class="mb-4 pl-3">
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-400">XP</span>
<span class="text-gray-300">
{skill.totalXp.toLocaleString()}
{#if !isMaxLevel}
/ {nextLevelXp.toLocaleString()}
{/if}
</span>
</div>
<div class="xp-bar-container h-2 rounded-full">
<div class="xp-bar h-full rounded-full" style="width: {progress}%"></div>
</div>
</div>
<!-- Description -->
{#if skill.description}
<p class="mb-4 pl-3 text-sm text-gray-400 line-clamp-2">{skill.description}</p>
{/if}
<!-- Actions -->
<div class="flex items-center gap-2 pl-3">
<button
onclick={onAddXp}
class="pulse-xp flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-600/20 px-3 py-2 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-600/30"
>
<Plus class="h-4 w-4" />
XP hinzufügen
</button>
<button
onclick={onEdit}
class="rounded-lg bg-gray-600/20 p-2 text-gray-400 opacity-0 transition-all hover:bg-gray-600/30 hover:text-white group-hover:opacity-100"
title="Bearbeiten"
>
<PencilSimple class="h-4 w-4" />
</button>
<button
onclick={onDelete}
class="rounded-lg bg-red-600/20 p-2 text-red-400 opacity-0 transition-all hover:bg-red-600/30 group-hover:opacity-100"
title="Löschen"
>
<Trash class="h-4 w-4" />
</button>
</div>
</div>

View file

@ -0,0 +1,210 @@
<script lang="ts">
import type { Skill, SkillBranch } from '../types';
import { BRANCH_INFO } from '../types';
import { X, Plus, Sparkle, Check } from '@manacore/shared-icons';
interface Props {
onClose: () => void;
onAddSkill: (skill: Partial<Skill>) => Promise<void>;
}
let { onClose, onAddSkill }: Props = $props();
interface SkillTemplate {
name: string;
description: string;
branch: SkillBranch;
}
const templates: Record<string, SkillTemplate[]> = {
'Web Developer': [
{ name: 'HTML & CSS', description: 'Grundlagen der Webentwicklung', branch: 'intellect' },
{ name: 'JavaScript', description: 'Die Sprache des Webs', branch: 'intellect' },
{ name: 'TypeScript', description: 'Typsicheres JavaScript', branch: 'intellect' },
{ name: 'React', description: 'UI-Bibliothek für moderne Apps', branch: 'intellect' },
{ name: 'Node.js', description: 'Backend mit JavaScript', branch: 'intellect' },
{ name: 'Git', description: 'Versionskontrolle', branch: 'practical' },
],
'Fitness & Gesundheit': [
{ name: 'Krafttraining', description: 'Muskelaufbau und Stärke', branch: 'body' },
{ name: 'Ausdauer', description: 'Cardio und Kondition', branch: 'body' },
{ name: 'Yoga', description: 'Flexibilität und Balance', branch: 'body' },
{ name: 'Ernährung', description: 'Gesunde Essgewohnheiten', branch: 'body' },
{ name: 'Schlaf', description: 'Erholsamer Schlaf', branch: 'mindset' },
{ name: 'Stressmanagement', description: 'Umgang mit Stress', branch: 'mindset' },
],
'Kreative Künste': [
{ name: 'Zeichnen', description: 'Grundlagen des Zeichnens', branch: 'creativity' },
{ name: 'Malen', description: 'Farben und Techniken', branch: 'creativity' },
{ name: 'Fotografie', description: 'Bilder einfangen', branch: 'creativity' },
{ name: 'Musik', description: 'Instrument spielen', branch: 'creativity' },
{ name: 'Schreiben', description: 'Kreatives Schreiben', branch: 'creativity' },
{ name: 'Design', description: 'Visuelles Design', branch: 'creativity' },
],
Sprachen: [
{ name: 'Englisch', description: 'Die Weltsprache', branch: 'intellect' },
{ name: 'Spanisch', description: 'Spanisch sprechen', branch: 'intellect' },
{ name: 'Französisch', description: 'La langue française', branch: 'intellect' },
{ name: 'Japanisch', description: '日本語', branch: 'intellect' },
{ name: 'Deutsch', description: 'Deutsche Sprache', branch: 'intellect' },
],
Produktivität: [
{ name: 'Zeitmanagement', description: 'Zeit effektiv nutzen', branch: 'mindset' },
{ name: 'Fokus', description: 'Konzentration verbessern', branch: 'mindset' },
{ name: 'Organisation', description: 'Ordnung und Struktur', branch: 'practical' },
{ name: 'Kommunikation', description: 'Klar kommunizieren', branch: 'social' },
{ name: 'Problemlösung', description: 'Analytisches Denken', branch: 'intellect' },
],
'Kochen & Haushalt': [
{ name: 'Kochen', description: 'Leckere Gerichte zubereiten', branch: 'practical' },
{ name: 'Backen', description: 'Süßes und Brot', branch: 'practical' },
{ name: 'Haushaltsführung', description: 'Sauberkeit und Ordnung', branch: 'practical' },
{ name: 'Gartenarbeit', description: 'Grüner Daumen', branch: 'practical' },
{ name: 'Heimwerken', description: 'Reparaturen selbst machen', branch: 'practical' },
],
};
let selectedTemplate = $state<string | null>(null);
let addedSkills = $state<Set<string>>(new Set());
let adding = $state(false);
async function addSkill(template: SkillTemplate) {
if (addedSkills.has(template.name)) return;
adding = true;
try {
await onAddSkill(template);
addedSkills = new Set([...addedSkills, template.name]);
} finally {
adding = false;
}
}
async function addAllFromTemplate(templateName: string) {
const skills = templates[templateName];
if (!skills) return;
adding = true;
try {
for (const skill of skills) {
if (!addedSkills.has(skill.name)) {
await onAddSkill(skill);
addedSkills = new Set([...addedSkills, skill.name]);
}
}
} finally {
adding = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-2xl rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl my-8">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
<Sparkle class="h-6 w-6 text-yellow-500" />
<h2 class="text-xl font-bold text-white">Skill-Vorlagen</h2>
</div>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X class="h-5 w-5" />
</button>
</div>
<p class="mb-6 text-gray-400">
Starte schnell mit vorgefertigten Skill-Sets. Wähle eine Vorlage und füge einzelne Skills oder
alle auf einmal hinzu.
</p>
<!-- Template List -->
<div class="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
{#each Object.entries(templates) as [name, skills]}
<div class="rounded-xl border border-gray-700 bg-gray-900/50 overflow-hidden">
<!-- Template Header -->
<div class="flex items-center justify-between p-4 hover:bg-gray-800/50 transition-colors">
<button
onclick={() => (selectedTemplate = selectedTemplate === name ? null : name)}
class="flex-1 text-left"
>
<h3 class="font-semibold text-white">{name}</h3>
<p class="text-sm text-gray-400">{skills.length} Skills</p>
</button>
<div class="flex items-center gap-2">
<button
onclick={() => addAllFromTemplate(name)}
disabled={adding}
class="rounded-lg bg-emerald-600/20 px-3 py-1.5 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-600/30 disabled:opacity-50"
>
Alle hinzufügen
</button>
<button
onclick={() => (selectedTemplate = selectedTemplate === name ? null : name)}
class="text-gray-500 text-xl px-2"
>
{selectedTemplate === name ? '' : '+'}
</button>
</div>
</div>
<!-- Expanded Skills -->
{#if selectedTemplate === name}
<div class="border-t border-gray-700 p-4 space-y-2">
{#each skills as skill}
{@const isAdded = addedSkills.has(skill.name)}
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-3 py-2">
<div class="flex items-center gap-3">
<span
class="h-3 w-3 rounded-full"
style="background-color: {BRANCH_INFO[skill.branch].color}"
></span>
<div>
<span class="font-medium text-white">{skill.name}</span>
<span class="text-gray-400 text-sm"> - {skill.description}</span>
</div>
</div>
<button
onclick={() => addSkill(skill)}
disabled={isAdded || adding}
class="rounded-lg p-1.5 transition-colors {isAdded
? 'bg-emerald-600/20 text-emerald-400'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
{#if isAdded}
<Check size={16} />
{:else}
<Plus class="h-4 w-4" />
{/if}
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer -->
<div class="mt-6 flex justify-end">
<button
onclick={onClose}
class="rounded-lg bg-gray-700 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-600"
>
Fertig
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { useAllSkills, useAllActivities, useAllAchievements, computeUserStats } from '../queries';
import { buildAchievementStatus, getAchievementStats } from '../stores/achievements.svelte';
import { Trophy, Lightning, Target, Fire, Medal } from '@manacore/shared-icons';
// Reactive live queries
const allSkills = useAllSkills();
const allActivities = useAllActivities();
const allAchievementsRaw = useAllAchievements();
let skills = $state<import('../types').Skill[]>([]);
let activities = $state<import('../types').Activity[]>([]);
let achievementsRaw = $state<import('../types').LocalAchievement[]>([]);
$effect(() => {
allSkills.subscribe((v) => (skills = v ?? []));
allActivities.subscribe((v) => (activities = v ?? []));
allAchievementsRaw.subscribe((v) => (achievementsRaw = v ?? []));
});
const userStats = $derived(computeUserStats(skills, activities));
const achievementStats = $derived(getAchievementStats(buildAchievementStatus(achievementsRaw)));
</script>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<!-- Total XP -->
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-500/20">
<Lightning class="h-6 w-6 text-yellow-500" />
</div>
<div>
<p class="text-sm text-gray-400">Gesamt-XP</p>
<p class="text-2xl font-bold text-white">
{userStats.totalXp.toLocaleString()}
</p>
</div>
</div>
</div>
<!-- Total Skills -->
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/20">
<Target class="h-6 w-6 text-emerald-500" />
</div>
<div>
<p class="text-sm text-gray-400">Skills</p>
<p class="text-2xl font-bold text-white">
{userStats.totalSkills}
</p>
</div>
</div>
</div>
<!-- Highest Level -->
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-purple-500/20">
<Trophy class="h-6 w-6 text-purple-500" />
</div>
<div>
<p class="text-sm text-gray-400">Hochstes Level</p>
<p class="text-2xl font-bold text-white">
{userStats.highestLevel}
</p>
</div>
</div>
</div>
<!-- Streak -->
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-orange-500/20">
<Fire class="h-6 w-6 text-orange-500" />
</div>
<div>
<p class="text-sm text-gray-400">Streak</p>
<p class="text-2xl font-bold text-white">
{userStats.streakDays} Tage
</p>
</div>
</div>
</div>
<!-- Achievements -->
<a
href="/skilltree/achievements"
class="rounded-xl border border-gray-700 bg-gray-800/50 p-4 transition-colors hover:border-yellow-600/50 hover:bg-yellow-900/10"
>
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-500/20">
<Medal class="h-6 w-6 text-yellow-500" />
</div>
<div>
<p class="text-sm text-gray-400">Achievements</p>
<p class="text-2xl font-bold text-white">
{achievementStats.unlocked}<span class="text-sm font-normal text-gray-500"
>/{achievementStats.total}</span
>
</p>
</div>
</div>
</a>
</div>

View file

@ -0,0 +1,59 @@
/**
* SkillTree module barrel exports.
*/
export { skillStore } from './stores/skills.svelte';
export {
achievementStore,
buildAchievementStatus,
getUnlockedAchievements,
getLockedAchievements,
getAchievementsByCategory,
getAchievementStats,
getCompletionPercentage,
} from './stores/achievements.svelte';
export {
useAllSkills,
useAllActivities,
useAllAchievements,
toSkill,
toActivity,
groupByBranch,
getTopSkills,
getRecentActivities,
computeBranchStats,
calculateStreak,
computeUserStats,
filterByBranch,
getSkillById,
getSkillActivities,
} from './queries';
export { skillTable, activityTable, achievementTable, SKILLTREE_GUEST_SEED } from './collections';
export type {
LocalSkill,
LocalActivity,
LocalAchievement,
Skill,
Activity,
SkillBranch,
UserStats,
AchievementCategory,
AchievementRarity,
AchievementCondition,
Achievement,
AchievementWithStatus,
AchievementUnlockResult,
} from './types';
export {
LEVEL_THRESHOLDS,
LEVEL_NAMES,
BRANCH_INFO,
RARITY_INFO,
ACHIEVEMENT_CATEGORY_INFO,
ACHIEVEMENT_DEFINITIONS,
calculateLevel,
xpForNextLevel,
xpProgress,
createDefaultSkill,
createActivity,
} from './types';

View file

@ -0,0 +1,181 @@
/**
* Reactive Queries & Pure Helpers for SkillTree
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalSkill, LocalActivity, LocalAchievement } from './types';
import type { Skill, Activity, SkillBranch, UserStats } from './types';
import { BRANCH_INFO } from './types';
// ─── Type Converters ───────────────────────────────────────
export function toSkill(local: LocalSkill): Skill {
return {
id: local.id,
name: local.name,
description: local.description,
branch: local.branch,
parentId: local.parentId ?? null,
icon: local.icon,
color: local.color ?? null,
currentXp: local.currentXp,
totalXp: local.totalXp,
level: local.level,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toActivity(local: LocalActivity): Activity {
return {
id: local.id,
skillId: local.skillId,
xpEarned: local.xpEarned,
description: local.description,
duration: local.duration ?? null,
timestamp: local.timestamp,
};
}
// ─── Live Queries (call during component init) ─────────────
/** All skills, auto-updates on any change. */
export function useAllSkills() {
return liveQuery(async () => {
const locals = await db.table<LocalSkill>('skills').toArray();
return locals.filter((s) => !s.deletedAt).map(toSkill);
});
}
/** All activities, auto-updates on any change. */
export function useAllActivities() {
return liveQuery(async () => {
const locals = await db.table<LocalActivity>('activities').toArray();
return locals.filter((a) => !a.deletedAt).map(toActivity);
});
}
/** All achievements (raw local records), auto-updates on any change. */
export function useAllAchievements() {
return liveQuery(async () => {
const locals = await db.table<LocalAchievement>('achievements').toArray();
return locals.filter((a) => !a.deletedAt);
});
}
// ─── Pure Filter/Helper Functions (for $derived) ──────────
/** Group skills by branch. */
export function groupByBranch(skills: Skill[]): Record<SkillBranch, Skill[]> {
const grouped: Record<SkillBranch, Skill[]> = {
intellect: [],
body: [],
creativity: [],
social: [],
practical: [],
mindset: [],
custom: [],
};
for (const skill of skills) {
grouped[skill.branch].push(skill);
}
return grouped;
}
/** Get top N skills by total XP. */
export function getTopSkills(skills: Skill[], n = 5): Skill[] {
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, n);
}
/** Get recent N activities sorted by timestamp descending. */
export function getRecentActivities(activities: Activity[], n = 10): Activity[] {
return [...activities]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, n);
}
/** Compute branch-level stats. */
export function computeBranchStats(
skills: Skill[]
): Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> {
const stats = {} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
const branchSkills = skills.filter((s) => s.branch === branch);
stats[branch] = {
count: branchSkills.length,
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
avgLevel:
branchSkills.length > 0
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
: 0,
};
}
return stats;
}
/** Calculate activity streak in days. */
export function calculateStreak(activities: Activity[]): number {
if (activities.length === 0) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
const sortedDates = activities
.map((a) => {
const d = new Date(a.timestamp);
d.setHours(0, 0, 0, 0);
return d.getTime();
})
.filter((v, i, a) => a.indexOf(v) === i)
.sort((a, b) => b - a);
let streak = 0;
let expectedDate = today.getTime();
for (const date of sortedDates) {
if (date === expectedDate || date === expectedDate - 86400000) {
streak++;
expectedDate = date - 86400000;
} else if (date < expectedDate - 86400000) {
break;
}
}
return streak;
}
/** Compute aggregate user stats from skills and activities. */
export function computeUserStats(skills: Skill[], activities: Activity[]): UserStats {
const sortedActivities = [...activities].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return {
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
totalSkills: skills.length,
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
streakDays: calculateStreak(activities),
lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null,
};
}
/** Filter skills by branch (or return all if 'all'). */
export function filterByBranch(skills: Skill[], branch: SkillBranch | 'all'): Skill[] {
if (branch === 'all') return skills;
return skills.filter((s) => s.branch === branch);
}
/** Find a skill by ID. */
export function getSkillById(skills: Skill[], id: string): Skill | undefined {
return skills.find((s) => s.id === id);
}
/** Get all activities for a specific skill. */
export function getSkillActivities(activities: Activity[], skillId: string): Activity[] {
return activities.filter((a) => a.skillId === skillId);
}

View file

@ -0,0 +1,229 @@
/**
* Achievements Store Write Actions + Unlock Queue
*
* Reads are handled by liveQuery hooks in queries.ts.
* This store handles achievement checking logic and the unlock celebration queue.
*/
import { db } from '$lib/data/database';
import type {
AchievementWithStatus,
AchievementUnlockResult,
AchievementCategory,
Skill,
Activity,
UserStats,
LocalAchievement,
} from '../types';
import { ACHIEVEMENT_DEFINITIONS } from '../types';
// Queue of recently unlocked achievements to show celebrations
let unlockQueue = $state<AchievementUnlockResult[]>([]);
// ─── Derived helpers (pure functions for consumers) ──────────
/** Build achievement status list from stored records and definitions. */
export function buildAchievementStatus(stored: LocalAchievement[]): AchievementWithStatus[] {
if (stored.length === 0) {
return ACHIEVEMENT_DEFINITIONS.map((def) => ({
...def,
unlocked: false,
unlockedAt: null,
progress: 0,
}));
}
return ACHIEVEMENT_DEFINITIONS.map((def) => {
const found = stored.find((s) => s.key === def.id || s.id === def.id);
return {
...def,
unlocked: found?.unlockedAt ? true : false,
unlockedAt: found?.unlockedAt || null,
progress: 0,
};
});
}
export function getUnlockedAchievements(
achievements: AchievementWithStatus[]
): AchievementWithStatus[] {
return achievements.filter((a) => a.unlocked);
}
export function getLockedAchievements(
achievements: AchievementWithStatus[]
): AchievementWithStatus[] {
return achievements.filter((a) => !a.unlocked);
}
export function getAchievementsByCategory(
achievements: AchievementWithStatus[]
): Record<AchievementCategory, AchievementWithStatus[]> {
const grouped: Record<AchievementCategory, AchievementWithStatus[]> = {
xp: [],
skills: [],
levels: [],
activities: [],
streak: [],
branches: [],
special: [],
};
for (const a of achievements) {
grouped[a.category].push(a);
}
return grouped;
}
export function getAchievementStats(achievements: AchievementWithStatus[]): {
total: number;
unlocked: number;
} {
return {
total: achievements.length,
unlocked: achievements.filter((a) => a.unlocked).length,
};
}
export function getCompletionPercentage(achievements: AchievementWithStatus[]): number {
if (achievements.length === 0) return 0;
return Math.round((achievements.filter((a) => a.unlocked).length / achievements.length) * 100);
}
// ─── Actions ─────────────────────────────────────────────────
async function seedIfEmpty() {
const stored = await db.table<LocalAchievement>('achievements').toArray();
const active = stored.filter((a) => !a.deletedAt);
if (active.length === 0) {
for (const def of ACHIEVEMENT_DEFINITIONS) {
await db.table<LocalAchievement>('achievements').add({
id: def.id,
key: def.id,
name: def.name,
description: def.description,
icon: def.icon,
unlockedAt: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
}
}
/**
* Check achievements locally (offline mode).
* Called after skill/activity changes.
*/
async function checkLocal(context: {
skills: Skill[];
activities: Activity[];
userStats: UserStats;
lastActivityXp?: number;
}): Promise<AchievementUnlockResult[]> {
const { skills, activities: allActivities, userStats: stats, lastActivityXp } = context;
// Get current achievements from DB
const stored = await db.table<LocalAchievement>('achievements').toArray();
const active = stored.filter((a) => !a.deletedAt);
const achievements = buildAchievementStatus(active);
const uniqueBranches = new Set(skills.map((s) => s.branch).filter((b) => b !== 'custom'));
const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset'];
const branchMaxLevels = new Map<string, number>();
for (const branch of mainBranches) {
const branchSkills = skills.filter((s) => s.branch === branch);
if (branchSkills.length > 0) {
branchMaxLevels.set(branch, Math.max(...branchSkills.map((s) => s.level)));
}
}
const allBranchesMinLevel =
branchMaxLevels.size === 6 ? Math.min(...branchMaxLevels.values()) : 0;
const userData = {
totalXp: stats.totalXp,
totalSkills: skills.length,
highestLevel: stats.highestLevel,
totalActivities: allActivities.length,
streakDays: stats.streakDays,
uniqueBranches: uniqueBranches.size,
allBranchesMinLevel,
lastActivityXp: lastActivityXp ?? 0,
};
const newlyUnlocked: AchievementUnlockResult[] = [];
for (const a of achievements) {
if (a.unlocked) continue;
const condition = a.condition;
let current = 0;
let met = false;
switch (condition.type) {
case 'total_xp':
current = userData.totalXp;
met = current >= condition.threshold;
break;
case 'total_skills':
current = userData.totalSkills;
met = current >= condition.threshold;
break;
case 'highest_level':
current = userData.highestLevel;
met = current >= condition.threshold;
break;
case 'total_activities':
current = userData.totalActivities;
met = current >= condition.threshold;
break;
case 'streak_days':
current = userData.streakDays;
met = current >= condition.threshold;
break;
case 'unique_branches':
current = userData.uniqueBranches;
met = current >= condition.threshold;
break;
case 'single_activity_xp':
current = userData.lastActivityXp;
met = current >= condition.threshold;
break;
case 'all_branches_min_level':
current = userData.allBranchesMinLevel;
met = current >= condition.threshold;
break;
}
if (met) {
const now = new Date().toISOString();
await db.table('achievements').update(a.id, {
unlockedAt: now,
updatedAt: now,
});
newlyUnlocked.push({ achievement: a, xpReward: a.xpReward });
}
}
if (newlyUnlocked.length > 0) {
unlockQueue = [...unlockQueue, ...newlyUnlocked];
}
return newlyUnlocked;
}
function popUnlockQueue(): AchievementUnlockResult | null {
if (unlockQueue.length === 0) return null;
const [first, ...rest] = unlockQueue;
unlockQueue = rest;
return first;
}
export const achievementStore = {
get unlockQueue() {
return unlockQueue;
},
seedIfEmpty,
checkLocal,
popUnlockQueue,
};

View file

@ -0,0 +1,112 @@
/**
* Skills Store Write Actions Only
*
* Reads are handled by liveQuery hooks in queries.ts.
* This store only exposes mutation actions that write to IndexedDB.
*/
import { db } from '$lib/data/database';
import type { Skill } from '../types';
import { calculateLevel, createDefaultSkill, createActivity } from '../types';
import type { LocalSkill, LocalActivity } from '../types';
import { SkillTreeEvents } from '@manacore/shared-utils/analytics';
// ─── Actions ─────────────────────────────────────────────────
async function addSkill(data: Partial<Skill>): Promise<Skill> {
const skill = createDefaultSkill(data);
const localSkill: LocalSkill = {
id: skill.id,
name: skill.name,
description: skill.description,
branch: skill.branch,
parentId: skill.parentId,
icon: skill.icon,
color: skill.color,
currentXp: skill.currentXp,
totalXp: skill.totalXp,
level: skill.level,
};
await db.table<LocalSkill>('skills').add({
...localSkill,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
SkillTreeEvents.skillCreated(data.branch || 'custom');
return skill;
}
async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
const localUpdates: Partial<LocalSkill> & { updatedAt: string } = {
updatedAt: new Date().toISOString(),
};
if (updates.name !== undefined) localUpdates.name = updates.name;
if (updates.description !== undefined) localUpdates.description = updates.description;
if (updates.branch !== undefined) localUpdates.branch = updates.branch;
if (updates.parentId !== undefined) localUpdates.parentId = updates.parentId;
if (updates.icon !== undefined) localUpdates.icon = updates.icon;
if (updates.color !== undefined) localUpdates.color = updates.color;
await db.table('skills').update(id, localUpdates);
}
async function deleteSkill(id: string): Promise<void> {
const now = new Date().toISOString();
// Soft-delete all activities for this skill
const skillActivities = await db
.table<LocalActivity>('activities')
.where('skillId')
.equals(id)
.toArray();
for (const a of skillActivities) {
await db.table('activities').update(a.id, { deletedAt: now, updatedAt: now });
}
await db.table('skills').update(id, { deletedAt: now, updatedAt: now });
SkillTreeEvents.skillDeleted();
}
async function addXp(
skillId: string,
xp: number,
description: string,
duration?: number
): Promise<{ leveledUp: boolean; newLevel: number }> {
const skill = await db.table<LocalSkill>('skills').get(skillId);
if (!skill) return { leveledUp: false, newLevel: 0 };
const newTotalXp = skill.totalXp + xp;
const newCurrentXp = skill.currentXp + xp;
const newLevel = calculateLevel(newTotalXp);
const leveledUp = newLevel > skill.level;
await db.table('skills').update(skillId, {
totalXp: newTotalXp,
currentXp: newCurrentXp,
level: newLevel,
updatedAt: new Date().toISOString(),
});
const activity = createActivity(skillId, xp, description, duration);
await db.table<LocalActivity>('activities').add({
id: activity.id,
skillId: activity.skillId,
xpEarned: activity.xpEarned,
description: activity.description,
duration: activity.duration,
timestamp: activity.timestamp,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
SkillTreeEvents.xpAdded(xp, leveledUp);
return { leveledUp, newLevel };
}
// Export store (write-only actions)
export const skillStore = {
addSkill,
updateSkill,
deleteSkill,
addXp,
};

View file

@ -0,0 +1,588 @@
/**
* SkillTree module types for the unified ManaCore app.
*/
import type { BaseRecord } from '@manacore/local-store';
// ─── Local Record Types (IndexedDB) ──────────────────────
export interface LocalSkill extends BaseRecord {
name: string;
description: string;
branch: 'intellect' | 'body' | 'creativity' | 'social' | 'practical' | 'mindset' | 'custom';
parentId?: string | null;
icon: string;
color?: string | null;
currentXp: number;
totalXp: number;
level: number;
}
export interface LocalActivity extends BaseRecord {
skillId: string;
xpEarned: number;
description: string;
duration?: number | null;
timestamp: string;
}
export interface LocalAchievement extends BaseRecord {
key: string;
name: string;
description: string;
icon: string;
unlockedAt: string;
}
// ─── Domain Types ─────────────────────────────────────────
export type SkillBranch =
| 'intellect'
| 'body'
| 'creativity'
| 'social'
| 'practical'
| 'mindset'
| 'custom';
export interface Skill {
id: string;
name: string;
description: string;
branch: SkillBranch;
parentId: string | null;
icon: string;
color: string | null;
currentXp: number;
totalXp: number;
level: number;
createdAt: string;
updatedAt: string;
}
export interface Activity {
id: string;
skillId: string;
xpEarned: number;
description: string;
duration: number | null; // minutes
timestamp: string;
}
export interface UserStats {
totalXp: number;
totalSkills: number;
highestLevel: number;
streakDays: number;
lastActivityDate: string | null;
}
// ─── Level System ─────────────────────────────────────────
export const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000] as const;
export const LEVEL_NAMES = [
'Unbekannt',
'Anfänger',
'Fortgeschritten',
'Kompetent',
'Experte',
'Meister',
] as const;
export const BRANCH_INFO: Record<
SkillBranch,
{ name: string; icon: string; color: string; description: string }
> = {
intellect: {
name: 'Intellekt',
icon: 'brain',
color: 'var(--color-branch-intellect)',
description: 'Wissen, Sprachen, Wissenschaft',
},
body: {
name: 'Körper',
icon: 'dumbbell',
color: 'var(--color-branch-body)',
description: 'Fitness, Sport, Gesundheit',
},
creativity: {
name: 'Kreativität',
icon: 'palette',
color: 'var(--color-branch-creativity)',
description: 'Kunst, Musik, Schreiben',
},
social: {
name: 'Sozial',
icon: 'users',
color: 'var(--color-branch-social)',
description: 'Kommunikation, Leadership, Empathie',
},
practical: {
name: 'Praktisch',
icon: 'wrench',
color: 'var(--color-branch-practical)',
description: 'Handwerk, Kochen, Technologie',
},
mindset: {
name: 'Mindset',
icon: 'heart',
color: 'var(--color-branch-mindset)',
description: 'Meditation, Fokus, Resilienz',
},
custom: {
name: 'Eigene',
icon: 'star',
color: 'var(--color-primary)',
description: 'Eigene Kategorien',
},
};
// ─── Helper Functions ─────────────────────────────────────
export function calculateLevel(xp: number): number {
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
if (xp >= LEVEL_THRESHOLDS[i]) {
return i;
}
}
return 0;
}
export function xpForNextLevel(currentLevel: number): number {
if (currentLevel >= LEVEL_THRESHOLDS.length - 1) {
return Infinity;
}
return LEVEL_THRESHOLDS[currentLevel + 1];
}
export function xpProgress(xp: number, level: number): number {
if (level >= LEVEL_THRESHOLDS.length - 1) {
return 100;
}
const currentThreshold = LEVEL_THRESHOLDS[level];
const nextThreshold = LEVEL_THRESHOLDS[level + 1];
const progress = ((xp - currentThreshold) / (nextThreshold - currentThreshold)) * 100;
return Math.min(100, Math.max(0, progress));
}
export function createDefaultSkill(partial: Partial<Skill> = {}): Skill {
const now = new Date().toISOString();
return {
id: crypto.randomUUID(),
name: '',
description: '',
branch: 'custom',
parentId: null,
icon: 'star',
color: null,
currentXp: 0,
totalXp: 0,
level: 0,
createdAt: now,
updatedAt: now,
...partial,
};
}
export function createActivity(
skillId: string,
xpEarned: number,
description: string,
duration?: number
): Activity {
return {
id: crypto.randomUUID(),
skillId,
xpEarned,
description,
duration: duration ?? null,
timestamp: new Date().toISOString(),
};
}
// ─── Achievement Types ────────────────────────────────────
export type AchievementCategory =
| 'xp'
| 'skills'
| 'levels'
| 'activities'
| 'streak'
| 'branches'
| 'special';
export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
export interface AchievementCondition {
type: string;
threshold: number;
}
export interface Achievement {
id: string;
name: string;
description: string;
icon: string;
category: AchievementCategory;
rarity: AchievementRarity;
xpReward: number;
sortOrder: number;
condition: AchievementCondition;
}
export interface AchievementWithStatus extends Achievement {
unlocked: boolean;
unlockedAt: string | null;
progress: number;
}
export interface AchievementUnlockResult {
achievement: Achievement;
xpReward: number;
}
export const RARITY_INFO: Record<
AchievementRarity,
{ name: string; color: string; bgColor: string; borderColor: string }
> = {
common: {
name: 'Gewöhnlich',
color: 'text-gray-300',
bgColor: 'bg-gray-700/50',
borderColor: 'border-gray-600',
},
uncommon: {
name: 'Ungewöhnlich',
color: 'text-green-400',
bgColor: 'bg-green-900/30',
borderColor: 'border-green-700',
},
rare: {
name: 'Selten',
color: 'text-blue-400',
bgColor: 'bg-blue-900/30',
borderColor: 'border-blue-700',
},
epic: {
name: 'Episch',
color: 'text-purple-400',
bgColor: 'bg-purple-900/30',
borderColor: 'border-purple-700',
},
legendary: {
name: 'Legendär',
color: 'text-yellow-400',
bgColor: 'bg-yellow-900/30',
borderColor: 'border-yellow-600',
},
};
export const ACHIEVEMENT_CATEGORY_INFO: Record<
AchievementCategory,
{ name: string; icon: string }
> = {
xp: { name: 'Erfahrung', icon: 'star' },
skills: { name: 'Skills', icon: 'grid' },
levels: { name: 'Level', icon: 'arrow-up' },
activities: { name: 'Aktivitäten', icon: 'lightning' },
streak: { name: 'Streak', icon: 'flame' },
branches: { name: 'Branches', icon: 'compass' },
special: { name: 'Speziell', icon: 'trophy' },
};
export const ACHIEVEMENT_DEFINITIONS: Achievement[] = [
// XP
{
id: 'xp_100',
name: 'Erste Schritte',
description: 'Sammle 100 XP insgesamt',
icon: 'star',
category: 'xp',
rarity: 'common',
xpReward: 10,
sortOrder: 1,
condition: { type: 'total_xp', threshold: 100 },
},
{
id: 'xp_1000',
name: 'Tausender-Club',
description: 'Sammle 1.000 XP insgesamt',
icon: 'star',
category: 'xp',
rarity: 'uncommon',
xpReward: 25,
sortOrder: 2,
condition: { type: 'total_xp', threshold: 1000 },
},
{
id: 'xp_5000',
name: 'XP-Sammler',
description: 'Sammle 5.000 XP insgesamt',
icon: 'star',
category: 'xp',
rarity: 'rare',
xpReward: 50,
sortOrder: 3,
condition: { type: 'total_xp', threshold: 5000 },
},
{
id: 'xp_10000',
name: 'XP-Legende',
description: 'Sammle 10.000 XP insgesamt',
icon: 'crown',
category: 'xp',
rarity: 'epic',
xpReward: 100,
sortOrder: 4,
condition: { type: 'total_xp', threshold: 10000 },
},
{
id: 'xp_50000',
name: 'Grenzenlos',
description: 'Sammle 50.000 XP insgesamt',
icon: 'crown',
category: 'xp',
rarity: 'legendary',
xpReward: 250,
sortOrder: 5,
condition: { type: 'total_xp', threshold: 50000 },
},
// Skills
{
id: 'skills_1',
name: 'Der Anfang',
description: 'Erstelle deinen ersten Skill',
icon: 'plus',
category: 'skills',
rarity: 'common',
xpReward: 10,
sortOrder: 10,
condition: { type: 'total_skills', threshold: 1 },
},
{
id: 'skills_5',
name: 'Vielseitig',
description: 'Erstelle 5 Skills',
icon: 'grid',
category: 'skills',
rarity: 'uncommon',
xpReward: 25,
sortOrder: 11,
condition: { type: 'total_skills', threshold: 5 },
},
{
id: 'skills_10',
name: 'Skill-Sammler',
description: 'Erstelle 10 Skills',
icon: 'grid',
category: 'skills',
rarity: 'rare',
xpReward: 50,
sortOrder: 12,
condition: { type: 'total_skills', threshold: 10 },
},
{
id: 'skills_20',
name: 'Meister aller Klassen',
description: 'Erstelle 20 Skills',
icon: 'grid',
category: 'skills',
rarity: 'epic',
xpReward: 100,
sortOrder: 13,
condition: { type: 'total_skills', threshold: 20 },
},
// Levels
{
id: 'level_1',
name: 'Anfänger',
description: 'Erreiche Level 1 mit einem Skill',
icon: 'arrow-up',
category: 'levels',
rarity: 'common',
xpReward: 15,
sortOrder: 20,
condition: { type: 'highest_level', threshold: 1 },
},
{
id: 'level_3',
name: 'Kompetent',
description: 'Erreiche Level 3 mit einem Skill',
icon: 'arrow-up',
category: 'levels',
rarity: 'rare',
xpReward: 50,
sortOrder: 21,
condition: { type: 'highest_level', threshold: 3 },
},
{
id: 'level_5',
name: 'Meister',
description: 'Erreiche Level 5 mit einem Skill',
icon: 'crown',
category: 'levels',
rarity: 'legendary',
xpReward: 200,
sortOrder: 22,
condition: { type: 'highest_level', threshold: 5 },
},
// Activities
{
id: 'activities_1',
name: 'Erste Aktion',
description: 'Logge deine erste Aktivität',
icon: 'lightning',
category: 'activities',
rarity: 'common',
xpReward: 5,
sortOrder: 30,
condition: { type: 'total_activities', threshold: 1 },
},
{
id: 'activities_10',
name: 'Dranbleiber',
description: 'Logge 10 Aktivitäten',
icon: 'lightning',
category: 'activities',
rarity: 'uncommon',
xpReward: 20,
sortOrder: 31,
condition: { type: 'total_activities', threshold: 10 },
},
{
id: 'activities_50',
name: 'Fleißig',
description: 'Logge 50 Aktivitäten',
icon: 'lightning',
category: 'activities',
rarity: 'rare',
xpReward: 50,
sortOrder: 32,
condition: { type: 'total_activities', threshold: 50 },
},
{
id: 'activities_100',
name: 'Unaufhaltsam',
description: 'Logge 100 Aktivitäten',
icon: 'fire',
category: 'activities',
rarity: 'epic',
xpReward: 100,
sortOrder: 33,
condition: { type: 'total_activities', threshold: 100 },
},
{
id: 'activities_500',
name: 'Maschine',
description: 'Logge 500 Aktivitäten',
icon: 'fire',
category: 'activities',
rarity: 'legendary',
xpReward: 250,
sortOrder: 34,
condition: { type: 'total_activities', threshold: 500 },
},
// Streak
{
id: 'streak_3',
name: '3-Tage-Streak',
description: 'Halte einen 3-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'common',
xpReward: 15,
sortOrder: 40,
condition: { type: 'streak_days', threshold: 3 },
},
{
id: 'streak_7',
name: 'Wochenkrieger',
description: 'Halte einen 7-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'uncommon',
xpReward: 30,
sortOrder: 41,
condition: { type: 'streak_days', threshold: 7 },
},
{
id: 'streak_14',
name: 'Zwei-Wochen-Held',
description: 'Halte einen 14-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'rare',
xpReward: 75,
sortOrder: 42,
condition: { type: 'streak_days', threshold: 14 },
},
{
id: 'streak_30',
name: 'Monatsmeister',
description: 'Halte einen 30-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'epic',
xpReward: 150,
sortOrder: 43,
condition: { type: 'streak_days', threshold: 30 },
},
{
id: 'streak_100',
name: 'Hundert Tage',
description: 'Halte einen 100-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'legendary',
xpReward: 500,
sortOrder: 44,
condition: { type: 'streak_days', threshold: 100 },
},
// Branches
{
id: 'branches_3',
name: 'Entdecker',
description: 'Habe Skills in 3 verschiedenen Branches',
icon: 'compass',
category: 'branches',
rarity: 'uncommon',
xpReward: 25,
sortOrder: 50,
condition: { type: 'unique_branches', threshold: 3 },
},
{
id: 'branches_all',
name: 'Universalgelehrter',
description: 'Habe Skills in allen 6 Branches',
icon: 'compass',
category: 'branches',
rarity: 'epic',
xpReward: 100,
sortOrder: 51,
condition: { type: 'unique_branches', threshold: 6 },
},
// Special
{
id: 'single_xp_100',
name: 'Mammut-Session',
description: 'Verdiene 100+ XP in einer einzelnen Aktivität',
icon: 'zap',
category: 'special',
rarity: 'rare',
xpReward: 25,
sortOrder: 60,
condition: { type: 'single_activity_xp', threshold: 100 },
},
{
id: 'all_branches_level_1',
name: 'Allrounder',
description: 'Erreiche Level 1 in allen 6 Branches',
icon: 'shield',
category: 'special',
rarity: 'epic',
xpReward: 150,
sortOrder: 61,
condition: { type: 'all_branches_min_level', threshold: 1 },
},
];

View file

@ -0,0 +1,37 @@
/**
* Zitare module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalFavorite, LocalQuoteList } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const favoriteTable = db.table<LocalFavorite>('zitareFavorites');
export const listTable = db.table<LocalQuoteList>('zitareLists');
// ─── Guest Seed ────────────────────────────────────────────
export const ZITARE_GUEST_SEED = {
zitareFavorites: [
{ id: 'fav-1', quoteId: 'mot-1' },
{ id: 'fav-2', quoteId: 'weis-3' },
{ id: 'fav-3', quoteId: 'mot-7' },
{ id: 'fav-4', quoteId: 'weis-1' },
{ id: 'fav-5', quoteId: 'liebe-1' },
],
zitareLists: [
{
id: 'list-motivation',
name: 'Motivation & Antrieb',
description: 'Zitate die dich voranbringen',
quoteIds: ['mot-1', 'mot-7', 'mot-3'],
},
{
id: 'list-weisheit',
name: 'Zeitlose Weisheiten',
description: 'Die großen Denker und Dichter',
quoteIds: ['weis-1', 'weis-3', 'weis-5'],
},
],
};

View file

@ -0,0 +1,177 @@
<script lang="ts">
import type { Quote, Category } from '@zitare/content';
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
import { favoritesStore } from '$lib/modules/zitare/stores/favorites.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { ZitareEvents } from '@manacore/shared-utils/analytics';
import { toast } from '$lib/stores/toast.svelte';
import { zitareSettings } from '$lib/modules/zitare/stores/settings.svelte';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/modules/zitare/queries';
import { Info, ShareNetwork, Heart } from '@manacore/shared-icons';
interface Props {
quote: Quote;
showCategory?: boolean;
showSource?: boolean;
size?: 'small' | 'medium' | 'large';
}
let { quote, showCategory = false, showSource = true, size = 'medium' }: Props = $props();
const allFavorites: { readonly value: Favorite[] } = getContext('favorites');
let isFavorite = $derived(checkIsFavorite(allFavorites.value, quote.id));
let quoteText = $derived(quotesStore.getText(quote));
let showBio = $state(false);
// Get author bio in current language
let authorBioText = $derived(() => {
if (!quote.authorBio) return '';
const lang = quotesStore.language === 'original' ? 'de' : quotesStore.language;
return quote.authorBio[lang] || quote.authorBio.de || '';
});
// Category gradient classes
const categoryGradients: Record<Category, string> = {
weisheit: 'quote-gradient-wisdom',
motivation: 'quote-gradient-motivation',
liebe: 'quote-gradient-love',
leben: 'quote-gradient-life',
erfolg: 'quote-gradient-success',
glueck: 'quote-gradient-happiness',
freundschaft: 'quote-gradient-friendship',
mut: 'quote-gradient-courage',
hoffnung: 'quote-gradient-hope',
natur: 'quote-gradient-nature',
};
// Category labels
const categoryLabels: Record<Category, string> = {
weisheit: 'categories.wisdom',
motivation: 'categories.motivation',
liebe: 'categories.love',
leben: 'categories.life',
erfolg: 'categories.success',
glueck: 'categories.happiness',
freundschaft: 'categories.friendship',
mut: 'categories.courage',
hoffnung: 'categories.hope',
natur: 'categories.nature',
};
async function toggleFavorite() {
if (!authStore.isAuthenticated) return;
const wasFavorite = isFavorite;
try {
await favoritesStore.toggle(quote.id, allFavorites.value);
if (wasFavorite) {
ZitareEvents.quoteUnfavorited();
} else {
ZitareEvents.quoteFavorited(quote.category);
}
} catch {
toast.error($_('common.error'));
}
}
async function shareQuote() {
const text = `"${quoteText}" — ${quote.author}`;
if (navigator.share) {
await navigator.share({
text,
title: 'Zitare',
});
} else {
await navigator.clipboard.writeText(text);
}
ZitareEvents.quoteShared(quote.category);
}
const sizeClasses = {
small: 'p-4 text-base',
medium: 'p-6 text-lg',
large: 'p-8 text-xl md:text-2xl',
};
</script>
<div
class="quote-card rounded-2xl bg-surface-elevated shadow-lg overflow-hidden {sizeClasses[size]}"
style="font-size: {zitareSettings.fontSizeMultiplier !== 1
? `${zitareSettings.fontSizeMultiplier}em`
: ''}"
>
{#if showCategory}
<div class="mb-4">
<span
class="inline-block px-3 py-1 rounded-full text-sm font-medium text-white {categoryGradients[
quote.category
]}"
>
{$_(categoryLabels[quote.category])}
</span>
</div>
{/if}
<blockquote class="quote-text text-foreground mb-4">
"{quoteText}"
</blockquote>
<div class="flex items-center justify-between">
<div>
<p class="quote-author text-foreground-secondary">
{quote.author}
{#if authorBioText}
<button
onclick={() => (showBio = !showBio)}
class="inline-flex ml-1 text-foreground-muted hover:text-primary transition-colors align-middle"
aria-label="Info"
>
<Info size={16} />
</button>
{/if}
</p>
{#if showBio && authorBioText}
<p class="text-sm text-foreground-muted mt-1 italic">{authorBioText}</p>
{/if}
{#if showSource && (quote.source || quote.year)}
<p class="text-sm text-foreground-muted mt-1">
{#if quote.source}
{quote.source}
{/if}
{#if quote.source && quote.year}
·
{/if}
{#if quote.year}
{quote.year}
{/if}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<button
onclick={shareQuote}
class="p-2 rounded-full hover:bg-surface-hover transition-colors text-foreground-secondary"
aria-label={$_('home.share')}
>
<ShareNetwork size={20} />
</button>
{#if authStore.isAuthenticated}
<button
onclick={toggleFavorite}
class="p-2 rounded-full hover:bg-surface-hover transition-colors"
aria-label={isFavorite ? $_('home.unfavorite') : $_('home.favorite')}
>
<Heart
size={20}
class="transition-colors {isFavorite
? 'text-red-500 fill-red-500'
: 'text-foreground-secondary'}"
/>
</button>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,165 @@
<script lang="ts">
import type { SpiralImage } from '@manacore/spiral-db';
import { spiralToXY, xyToSpiral } from '@manacore/spiral-db';
interface Props {
image: SpiralImage;
scale?: number;
showGrid?: boolean;
highlightIndex?: number | null;
onPixelClick?: (index: number, x: number, y: number) => void;
}
let {
image,
scale = 10,
showGrid = false,
highlightIndex = null,
onPixelClick,
}: Props = $props();
let canvas: HTMLCanvasElement;
let hoveredIndex = $state<number | null>(null);
$effect(() => {
if (!canvas || !image) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { width, height, pixels } = image;
canvas.width = width * scale;
canvas.height = height * scale;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 3;
const r = pixels[offset];
const g = pixels[offset + 1];
const b = pixels[offset + 2];
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(x * scale, y * scale, scale, scale);
}
}
if (showGrid && scale >= 8) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
for (let x = 0; x <= width; x++) {
ctx.beginPath();
ctx.moveTo(x * scale, 0);
ctx.lineTo(x * scale, height * scale);
ctx.stroke();
}
for (let y = 0; y <= height; y++) {
ctx.beginPath();
ctx.moveTo(0, y * scale);
ctx.lineTo(width * scale, y * scale);
ctx.stroke();
}
}
const center = Math.floor(width / 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(center * scale, center * scale, scale, scale);
if (highlightIndex !== null && highlightIndex >= 0) {
const point = spiralToXY(highlightIndex, width);
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
}
if (hoveredIndex !== null) {
const point = spiralToXY(hoveredIndex, width);
ctx.strokeStyle = '#8b5cf6';
ctx.lineWidth = 2;
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
}
});
function handleMouseMove(e: MouseEvent) {
if (!canvas || !image) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
hoveredIndex = xyToSpiral(x, y, image.width);
} else {
hoveredIndex = null;
}
}
function handleMouseLeave() {
hoveredIndex = null;
}
function handleClick(e: MouseEvent) {
if (!canvas || !image || !onPixelClick) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
const index = xyToSpiral(x, y, image.width);
onPixelClick(index, x, y);
}
}
</script>
<div class="spiral-canvas-container">
<canvas
bind:this={canvas}
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
onclick={handleClick}
class="spiral-canvas"
class:clickable={!!onPixelClick}
></canvas>
{#if hoveredIndex !== null}
<div class="pixel-info">
Pixel #{hoveredIndex}
</div>
{/if}
</div>
<style>
.spiral-canvas-container {
position: relative;
display: inline-block;
}
.spiral-canvas {
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.spiral-canvas.clickable {
cursor: pointer;
}
.pixel-info {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,21 @@
/**
* Zitare module barrel exports.
*/
export { favoritesStore } from './stores/favorites.svelte';
export { listsStore } from './stores/lists.svelte';
export { quotesStore } from './stores/quotes.svelte';
export { zitareSettings } from './stores/settings.svelte';
export { spiralStore } from './stores/spiral.svelte';
export {
useAllFavorites,
useAllLists,
toFavorite,
toQuoteList,
isFavorite,
findFavoriteByQuoteId,
findListById,
} from './queries';
export type { Favorite, QuoteList } from './queries';
export { favoriteTable, listTable, ZITARE_GUEST_SEED } from './collections';
export type { LocalFavorite, LocalQuoteList } from './types';

View file

@ -0,0 +1,83 @@
/**
* Reactive queries for Zitare uses Dexie liveQuery on the unified DB.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalFavorite, LocalQuoteList } from './types';
// ─── Domain Types ─────────────────────────────────────────
export interface Favorite {
id: string;
quoteId: string;
createdAt: string;
}
export interface QuoteList {
id: string;
name: string;
description?: string;
quoteIds: string[];
createdAt: string;
updatedAt: string;
}
// ─── Type Converters ──────────────────────────────────────
export function toFavorite(local: LocalFavorite): Favorite {
return {
id: local.id,
quoteId: local.quoteId,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toQuoteList(local: LocalQuoteList): QuoteList {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
quoteIds: local.quoteIds,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ─────────────────────────────────────────
/** All favorites. Auto-updates on any change. */
export function useAllFavorites() {
return liveQuery(async () => {
const locals = await db.table<LocalFavorite>('zitareFavorites').toArray();
return locals.filter((f) => !f.deletedAt).map(toFavorite);
});
}
/** All lists. Auto-updates on any change. */
export function useAllLists() {
return liveQuery(async () => {
const locals = await db.table<LocalQuoteList>('zitareLists').toArray();
return locals.filter((l) => !l.deletedAt).map(toQuoteList);
});
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Check if a quote is in the favorites list. */
export function isFavorite(favorites: Favorite[], quoteId: string): boolean {
return favorites.some((f) => f.quoteId === quoteId);
}
/** Find a favorite by quote ID. */
export function findFavoriteByQuoteId(
favorites: Favorite[],
quoteId: string
): Favorite | undefined {
return favorites.find((f) => f.quoteId === quoteId);
}
/** Find a list by ID. */
export function findListById(lists: QuoteList[], listId: string): QuoteList | undefined {
return lists.find((l) => l.id === listId);
}

View file

@ -0,0 +1,40 @@
/**
* Favorites Store Mutation-only
* Reads come from liveQuery via queries.ts (reactive, auto-updating).
* This store only handles write operations.
*/
import { db } from '$lib/data/database';
import type { LocalFavorite } from '../types';
import type { Favorite } from '../queries';
export const favoritesStore = {
async add(quoteId: string) {
const now = new Date().toISOString();
await db.table<LocalFavorite>('zitareFavorites').add({
id: crypto.randomUUID(),
quoteId,
createdAt: now,
updatedAt: now,
});
},
async remove(quoteId: string, favorites: Favorite[]) {
const fav = favorites.find((f) => f.quoteId === quoteId);
if (fav) {
await db.table('zitareFavorites').update(fav.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
},
async toggle(quoteId: string, favorites: Favorite[]) {
const exists = favorites.some((f) => f.quoteId === quoteId);
if (exists) {
await this.remove(quoteId, favorites);
} else {
await this.add(quoteId);
}
},
};

View file

@ -0,0 +1,101 @@
/**
* Lists Store Mutation-only
* Reads come from liveQuery via queries.ts (reactive, auto-updating).
* This store only handles write operations.
*/
import { db } from '$lib/data/database';
import type { LocalQuoteList } from '../types';
import { toQuoteList, type QuoteList } from '../queries';
export type { QuoteList } from '../queries';
export const listsStore = {
async getList(id: string): Promise<QuoteList | null> {
const local = await db.table<LocalQuoteList>('zitareLists').get(id);
return local ? toQuoteList(local) : null;
},
async createList(name: string, description?: string): Promise<QuoteList | null> {
try {
const now = new Date().toISOString();
const newLocal: LocalQuoteList = {
id: crypto.randomUUID(),
name,
description: description ?? null,
quoteIds: [],
createdAt: now,
updatedAt: now,
};
await db.table<LocalQuoteList>('zitareLists').add(newLocal);
return toQuoteList(newLocal);
} catch {
return null;
}
},
async updateList(
id: string,
updates: { name?: string; description?: string }
): Promise<QuoteList | null> {
try {
await db.table('zitareLists').update(id, {
...updates,
updatedAt: new Date().toISOString(),
});
const updated = await db.table<LocalQuoteList>('zitareLists').get(id);
return updated ? toQuoteList(updated) : null;
} catch {
return null;
}
},
async deleteList(id: string): Promise<boolean> {
try {
await db.table('zitareLists').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return true;
} catch {
return false;
}
},
async addQuoteToList(listId: string, quoteId: string): Promise<boolean> {
try {
const existing = await db.table<LocalQuoteList>('zitareLists').get(listId);
if (!existing) return false;
const quoteIds = [...(existing.quoteIds || [])];
if (!quoteIds.includes(quoteId)) {
quoteIds.push(quoteId);
}
await db.table('zitareLists').update(listId, {
quoteIds,
updatedAt: new Date().toISOString(),
});
return true;
} catch {
return false;
}
},
async removeQuoteFromList(listId: string, quoteId: string): Promise<boolean> {
try {
const existing = await db.table<LocalQuoteList>('zitareLists').get(listId);
if (!existing) return false;
const quoteIds = (existing.quoteIds || []).filter((qid) => qid !== quoteId);
await db.table('zitareLists').update(listId, {
quoteIds,
updatedAt: new Date().toISOString(),
});
return true;
} catch {
return false;
}
},
};

View file

@ -0,0 +1,117 @@
/**
* Quotes Store - Manages quote display state
*/
import { browser } from '$app/environment';
import {
QUOTES,
getDailyQuote,
getRandomQuote,
getQuotesByCategory,
searchQuotes,
getQuoteText,
type Quote,
type Category,
type SupportedLanguage,
} from '@zitare/content';
// State
let currentQuote = $state<Quote | null>(null);
let language = $state<SupportedLanguage>('de');
// Get stored language or detect from browser
function getInitialLanguage(): SupportedLanguage {
if (browser) {
const stored = localStorage.getItem('zitare_quote_language');
if (stored && ['de', 'en', 'it', 'fr', 'es', 'original'].includes(stored)) {
return stored as SupportedLanguage;
}
// Map browser language to supported language
const browserLang = navigator.language.split('-')[0];
const langMap: Record<string, SupportedLanguage> = {
de: 'de',
en: 'en',
it: 'it',
fr: 'fr',
es: 'es',
};
return langMap[browserLang] || 'de';
}
return 'de';
}
export const quotesStore = {
get currentQuote() {
return currentQuote;
},
get language() {
return language;
},
get allQuotes() {
return QUOTES;
},
get totalCount() {
return QUOTES.length;
},
/**
* Initialize the store
*/
initialize() {
language = getInitialLanguage();
currentQuote = getDailyQuote();
},
/**
* Set the display language
*/
setLanguage(lang: SupportedLanguage) {
language = lang;
if (browser) {
localStorage.setItem('zitare_quote_language', lang);
}
},
/**
* Get quote text in current language
*/
getText(quote: Quote): string {
return getQuoteText(quote, language);
},
/**
* Load the daily quote
*/
loadDailyQuote() {
currentQuote = getDailyQuote();
},
/**
* Load a random quote
*/
loadRandomQuote() {
currentQuote = getRandomQuote();
},
/**
* Get quotes by category
*/
getByCategory(category: Category): Quote[] {
return getQuotesByCategory(category);
},
/**
* Search quotes
*/
search(query: string): Quote[] {
return searchQuotes(query, language);
},
/**
* Set current quote
*/
setCurrentQuote(quote: Quote) {
currentQuote = quote;
},
};

View file

@ -0,0 +1,96 @@
/**
* Settings Store - Manages user preferences for the Zitare module
* Uses @manacore/shared-stores createAppSettingsStore factory
*/
import { createAppSettingsStore } from '@manacore/shared-stores';
export interface ZitareAppSettings extends Record<string, unknown> {
// View & Display
showQuoteOfTheDay: boolean;
autoRefreshDaily: boolean;
compactMode: boolean;
// Quote Display
showCategory: boolean;
showSource: boolean;
fontSizeMultiplier: number;
// Immersive Mode
immersiveModeEnabled: boolean;
// Navigation UI
pillNavCollapsed: boolean;
}
const DEFAULT_SETTINGS: ZitareAppSettings = {
// View & Display
showQuoteOfTheDay: true,
autoRefreshDaily: true,
compactMode: false,
// Quote Display
showCategory: true,
showSource: true,
fontSizeMultiplier: 1,
// Immersive Mode
immersiveModeEnabled: false,
// Navigation UI
pillNavCollapsed: true,
};
// Create base store using factory
const baseStore = createAppSettingsStore<ZitareAppSettings>('zitare-settings', DEFAULT_SETTINGS);
// Export with convenience getters
export const zitareSettings = {
// Base store methods
get settings() {
return baseStore.settings;
},
initialize: baseStore.initialize,
set: baseStore.set,
update: baseStore.update,
reset: baseStore.reset,
getDefaults: baseStore.getDefaults,
toggleImmersiveMode: baseStore.toggleImmersiveMode,
// Convenience getters
get showQuoteOfTheDay() {
return baseStore.settings.showQuoteOfTheDay;
},
get autoRefreshDaily() {
return baseStore.settings.autoRefreshDaily;
},
get compactMode() {
return baseStore.settings.compactMode;
},
get showCategory() {
return baseStore.settings.showCategory;
},
get showSource() {
return baseStore.settings.showSource;
},
get fontSizeMultiplier() {
return baseStore.settings.fontSizeMultiplier;
},
get immersiveModeEnabled() {
return baseStore.settings.immersiveModeEnabled;
},
get pillNavCollapsed() {
return baseStore.settings.pillNavCollapsed;
},
// Toggle methods
togglePillNav() {
baseStore.update({ pillNavCollapsed: !baseStore.settings.pillNavCollapsed });
},
showPillNav() {
baseStore.update({ pillNavCollapsed: false });
},
hidePillNav() {
baseStore.update({ pillNavCollapsed: true });
},
};

View file

@ -0,0 +1,233 @@
/**
* Spiral DB Store for Zitare
* Manages SpiralDB state for visual quote storage
*/
import {
SpiralDB,
createQuoteSchema,
type SpiralImage,
type SpiralRecord,
exportToPngBytes,
importFromPngBytes,
downloadPng,
} from '@manacore/spiral-db';
interface QuoteData extends Record<string, unknown> {
id: number;
status: number;
category: number;
language: number;
createdAt: Date;
quoteId: string;
author: string;
text: string;
}
interface SpiralStats {
imageSize: number;
totalPixels: number;
usedPixels: number;
totalRecords: number;
activeRecords: number;
deletedRecords: number;
currentRing: number;
compressionRatio: number;
}
const CATEGORY_MAP: Record<string, number> = {
motivation: 0,
weisheit: 1,
liebe: 2,
leben: 3,
erfolg: 4,
glueck: 5,
freundschaft: 6,
mut: 7,
hoffnung: 8,
natur: 9,
};
const CATEGORY_NAMES: Record<number, string> = Object.fromEntries(
Object.entries(CATEGORY_MAP).map(([k, v]) => [v, k])
);
const LANGUAGE_MAP: Record<string, number> = {
original: 0,
de: 1,
en: 2,
it: 3,
fr: 4,
es: 5,
};
class SpiralStore {
private db: SpiralDB<QuoteData>;
image = $state<SpiralImage | null>(null);
stats = $state<SpiralStats | null>(null);
records = $state<SpiralRecord<QuoteData>[]>([]);
isLoading = $state(false);
error = $state<string | null>(null);
constructor() {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
this.updateState();
}
private updateState() {
this.image = this.db.getImage();
this.records = this.db.getAll();
const dbStats = this.db.getStats();
const jsonSize = JSON.stringify(this.records.map((r) => r.data)).length || 1;
const pixelBytes = Math.ceil((dbStats.usedPixels * 3) / 8);
this.stats = {
...dbStats,
compressionRatio: Math.round((1 - pixelBytes / jsonSize) * 100),
};
}
/**
* Import favorites from the favorites store, merged with quote data
*/
importFavorites(
favorites: Array<{
quoteId: string;
createdAt?: string | Date;
}>,
getQuote: (quoteId: string) => {
author: string;
text: string;
category: string;
language?: string;
} | null
) {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
for (const fav of favorites) {
const quote = getQuote(fav.quoteId);
if (!quote) continue;
const result = this.db.insert({
id: 0,
status: 2, // favorited
category: CATEGORY_MAP[quote.category] ?? 0,
language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1,
createdAt: fav.createdAt ? new Date(fav.createdAt) : new Date(),
quoteId: fav.quoteId.slice(0, 100),
author: quote.author.slice(0, 100),
text: quote.text.slice(0, 255),
});
if (result.success) {
this.db.complete(result.recordId!);
}
}
this.updateState();
}
/**
* Add a single quote to the spiral
*/
addQuote(quote: {
quoteId: string;
author: string;
text: string;
category: string;
language?: string;
}) {
const result = this.db.insert({
id: 0,
status: 0,
category: CATEGORY_MAP[quote.category] ?? 0,
language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1,
createdAt: new Date(),
quoteId: quote.quoteId.slice(0, 100),
author: quote.author.slice(0, 100),
text: quote.text.slice(0, 255),
});
if (result.success) {
this.updateState();
}
return result;
}
/**
* Remove a quote (soft delete)
*/
removeQuote(id: number) {
const result = this.db.delete(id);
if (result.success) {
this.updateState();
}
return result;
}
/**
* Mark a quote as favorited
*/
favoriteQuote(id: number) {
const result = this.db.complete(id);
if (result.success) {
this.updateState();
}
return result;
}
downloadPng(filename = 'spiral-quotes.png') {
if (this.image) {
downloadPng(this.image, filename);
}
}
getPngBytes(): Uint8Array | null {
if (!this.image) return null;
return exportToPngBytes(this.image);
}
clear() {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
this.updateState();
}
async importFromPng(file: File): Promise<{ success: boolean; error?: string }> {
try {
this.isLoading = true;
this.error = null;
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const image = await importFromPngBytes(bytes);
this.db = SpiralDB.fromImage<QuoteData>(image, createQuoteSchema());
this.updateState();
return { success: true };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
this.error = errorMessage;
return { success: false, error: errorMessage };
} finally {
this.isLoading = false;
}
}
getCategoryName(index: number): string {
return CATEGORY_NAMES[index] ?? 'unknown';
}
}
export const spiralStore = new SpiralStore();

View file

@ -0,0 +1,15 @@
/**
* Zitare module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalFavorite extends BaseRecord {
quoteId: string;
}
export interface LocalQuoteList extends BaseRecord {
name: string;
description?: string | null;
quoteIds: string[];
}

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllAlarms, useAllTimers, useAllWorldClocks } from '$lib/modules/clock/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allAlarms = useAllAlarms();
const allTimers = useAllTimers();
const allWorldClocks = useAllWorldClocks();
// Provide data to child components via Svelte context
setContext('alarms', allAlarms);
setContext('timers', allTimers);
setContext('worldClocks', allWorldClocks);
</script>
{@render children()}

View file

@ -0,0 +1,93 @@
<script lang="ts">
import { Clock, Bell, Timer, Hourglass, Globe } from '@manacore/shared-icons';
const quickLinks = [
{
href: '/clock/world-clock',
icon: Globe,
label: 'Weltzeituhr',
description: 'Zeitzonen im Blick',
color: 'bg-blue-500',
},
{
href: '/clock/alarms',
icon: Bell,
label: 'Wecker',
description: 'Alarme verwalten',
color: 'bg-amber-500',
},
{
href: '/clock/timers',
icon: Timer,
label: 'Timer',
description: 'Countdowns starten',
color: 'bg-green-500',
},
{
href: '/clock/stopwatch',
icon: Hourglass,
label: 'Stoppuhr',
description: 'Zeit messen',
color: 'bg-purple-500',
},
];
</script>
<svelte:head>
<title>Clock - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Clock</h1>
<p class="text-muted-foreground mt-1 text-sm">Dein Zeit-Management Hub</p>
</header>
<!-- Current Time Display -->
<div class="mb-8 rounded-xl border border-border bg-card p-6">
<div class="flex items-center gap-4">
<div class="rounded-full bg-primary/10 p-3">
<Clock size={32} class="text-primary" />
</div>
<div>
<div class="text-4xl font-bold tabular-nums text-foreground">
{new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
<div class="text-muted-foreground">
{new Date().toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</div>
</div>
</div>
<!-- Quick Links Grid -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
{#each quickLinks as link}
<a
href={link.href}
class="rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-lg group"
>
<div class="flex flex-col items-center gap-3 text-center">
<div
class="{link.color} rounded-full p-3 text-white transition-transform group-hover:scale-110"
>
<link.icon size={24} />
</div>
<div>
<div class="font-medium text-foreground">{link.label}</div>
<div class="text-xs text-muted-foreground">{link.description}</div>
</div>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,327 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { PageHeader, toast } from '@manacore/shared-ui';
import { alarmsStore } from '$lib/modules/clock/stores/alarms.svelte';
import type { Alarm } from '@clock/shared';
import { ALARM_SOUNDS, DEFAULT_ALARM_PRESETS } from '@clock/shared';
// Get live query data from layout context
const allAlarms: { readonly value: Alarm[] } = getContext('alarms');
// Quick create form (inline)
let newTime = $state('07:00');
let newLabel = $state('');
let newRepeatDays = $state<number[]>([]);
let showOptions = $state(false);
// Edit modal state
let showEditModal = $state(false);
let editingId = $state<string | null>(null);
let editTime = $state('07:00');
let editLabel = $state('');
let editRepeatDays = $state<number[]>([]);
let editSound = $state('default');
let editSnoozeMinutes = $state(5);
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// Find existing alarm for a preset time
function findAlarmForPreset(presetTime: string): Alarm | undefined {
return allAlarms.value.find((a) => a.time.slice(0, 5) === presetTime);
}
// Toggle a preset alarm
async function togglePreset(presetTime: string, presetLabel: string) {
const existingAlarm = findAlarmForPreset(presetTime);
if (existingAlarm) {
await alarmsStore.toggleAlarm(existingAlarm.id, allAlarms.value);
} else {
const result = await alarmsStore.createAlarm({
time: presetTime + ':00',
label: presetLabel,
enabled: true,
});
if (result.success) {
toast.success('Wecker erstellt');
} else {
toast.error(result.error || 'Fehler beim Erstellen');
}
}
}
// Quick create new alarm
async function handleQuickCreate() {
const result = await alarmsStore.createAlarm({
time: newTime + ':00',
label: newLabel || undefined,
repeatDays: newRepeatDays.length > 0 ? newRepeatDays : undefined,
enabled: true,
});
if (result.success) {
toast.success('Wecker erstellt');
// Reset form
newTime = '07:00';
newLabel = '';
newRepeatDays = [];
showOptions = false;
} else {
toast.error(result.error || 'Fehler beim Erstellen');
}
}
function toggleNewDay(day: number) {
if (newRepeatDays.includes(day)) {
newRepeatDays = newRepeatDays.filter((d) => d !== day);
} else {
newRepeatDays = [...newRepeatDays, day];
}
}
function openEditModal(alarm: Alarm) {
editingId = alarm.id;
editTime = alarm.time.slice(0, 5);
editLabel = alarm.label || '';
editRepeatDays = alarm.repeatDays || [];
editSound = alarm.sound || 'default';
editSnoozeMinutes = alarm.snoozeMinutes || 5;
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingId = null;
}
function toggleEditDay(day: number) {
if (editRepeatDays.includes(day)) {
editRepeatDays = editRepeatDays.filter((d) => d !== day);
} else {
editRepeatDays = [...editRepeatDays, day];
}
}
async function handleEditSubmit() {
if (!editingId) return;
const result = await alarmsStore.updateAlarm(editingId, {
time: editTime + ':00',
label: editLabel || undefined,
repeatDays: editRepeatDays.length > 0 ? editRepeatDays : undefined,
sound: editSound,
snoozeMinutes: editSnoozeMinutes,
});
if (result.success) {
toast.success('Wecker aktualisiert');
closeEditModal();
} else {
toast.error(result.error || 'Fehler beim Speichern');
}
}
async function handleDelete(id: string) {
const result = await alarmsStore.deleteAlarm(id);
if (result.success) {
toast.success('Wecker gelöscht');
} else {
toast.error(result.error || 'Fehler beim Löschen');
}
}
async function handleToggle(id: string) {
await alarmsStore.toggleAlarm(id, allAlarms.value);
}
function getRepeatText(days: number[] | null) {
if (!days || days.length === 0) return 'Einmalig';
if (days.length === 7) return 'Täglich';
if (days.length === 5 && [1, 2, 3, 4, 5].every((d) => days.includes(d))) return 'Wochentags';
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Am Wochenende';
return days.map((d) => dayNames[d]).join(', ');
}
</script>
<svelte:head>
<title>Wecker - Clock - ManaCore</title>
</svelte:head>
<PageHeader title={$_('alarm.title')} size="md" centered />
<div class="space-y-4">
<!-- Quick Create Form -->
<div class="quick-create">
<input type="time" class="time-input-inline" bind:value={newTime} />
<input type="text" class="label-input" placeholder="Bezeichnung" bind:value={newLabel} />
<button
class="text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
onclick={() => (showOptions = !showOptions)}
title="Wiederholung"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
class:text-primary={newRepeatDays.length > 0}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clip-rule="evenodd"
/>
</svg>
</button>
<button class="btn btn-primary btn-sm" onclick={handleQuickCreate}> + </button>
</div>
{#if showOptions}
<div class="day-selector-compact">
{#each dayNames as day, i}
<button
type="button"
class:active={newRepeatDays.includes(i)}
onclick={() => toggleNewDay(i)}
>
{day}
</button>
{/each}
</div>
{/if}
<!-- Default Alarm Presets (Grid) -->
<div class="alarm-grid">
{#each DEFAULT_ALARM_PRESETS as preset}
{@const existingAlarm = findAlarmForPreset(preset.time)}
{@const isActive = existingAlarm?.enabled ?? false}
<div
class="alarm-tile"
class:active={isActive}
role="button"
tabindex="0"
onclick={() => togglePreset(preset.time, preset.label)}
onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{preset.time}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{existingAlarm?.label || preset.label}
</div>
</div>
{/each}
</div>
<!-- Custom Alarms (Grid) -->
{#if allAlarms.value.filter((a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))).length > 0}
{@const customAlarms = allAlarms.value.filter(
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
)}
<div class="mt-4">
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('alarm.custom')}
</h2>
<div class="alarm-grid">
{#each customAlarms as alarm (alarm.id)}
<div
class="alarm-tile"
class:active={alarm.enabled}
role="button"
tabindex="0"
onclick={() => handleToggle(alarm.id)}
onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{alarm.time.slice(0, 5)}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{alarm.label || getRepeatText(alarm.repeatDays)}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Edit Modal -->
{#if showEditModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="card w-full max-w-md">
<h2 class="mb-4 text-xl font-semibold">{$_('alarm.edit')}</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleEditSubmit();
}}
>
<!-- Time -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.time')}</label>
<input type="time" class="input time-input" bind:value={editTime} />
</div>
<!-- Label -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.label')}</label>
<input
type="text"
class="input"
placeholder="Arbeit, Sport, etc."
bind:value={editLabel}
/>
</div>
<!-- Repeat Days -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">{$_('alarm.repeat')}</label>
<div class="day-selector">
{#each dayNames as day, i}
<button
type="button"
class:active={editRepeatDays.includes(i)}
onclick={() => toggleEditDay(i)}
>
{day}
</button>
{/each}
</div>
</div>
<!-- Sound -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.sound')}</label>
<select class="input" bind:value={editSound}>
{#each ALARM_SOUNDS as sound}
<option value={sound.id}>{sound.nameDE}</option>
{/each}
</select>
</div>
<!-- Snooze -->
<div class="mb-6">
<label class="mb-1 block text-sm font-medium">{$_('alarm.snooze')}</label>
<select class="input" bind:value={editSnoozeMinutes}>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button type="button" class="btn btn-secondary flex-1" onclick={closeEditModal}>
{$_('common.cancel')}
</button>
<button type="submit" class="btn btn-primary flex-1">
{$_('common.save')}
</button>
</div>
</form>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Lamp, MusicNotes, Palette } from '@manacore/shared-icons';
const quickLinks = [
{
href: '/moodlit/moods',
icon: Lamp,
label: 'Moods',
description: 'Stimmungslichter',
color: 'bg-purple-500',
},
{
href: '/moodlit/sequences',
icon: MusicNotes,
label: 'Sequences',
description: 'Mood-Abfolgen',
color: 'bg-indigo-500',
},
];
</script>
<svelte:head>
<title>Moodlit - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Moodlit</h1>
<p class="text-muted-foreground mt-1 text-sm">Ambient Lighting & Mood App</p>
</header>
<!-- Hero display -->
<div class="mb-8 overflow-hidden rounded-xl border border-border bg-card p-6">
<div class="flex items-center gap-4">
<div class="rounded-full bg-purple-500/10 p-3">
<Palette size={32} class="text-purple-500" />
</div>
<div>
<div class="text-2xl font-bold text-foreground">Stimmungslicht</div>
<div class="text-muted-foreground text-sm">Wahle ein Mood oder erstelle dein eigenes</div>
</div>
</div>
</div>
<!-- Quick Links Grid -->
<div class="grid grid-cols-2 gap-4">
{#each quickLinks as link}
<a
href={link.href}
class="rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg group"
>
<div class="flex flex-col items-center gap-3 text-center">
<div
class="{link.color} rounded-full p-3 text-white transition-transform group-hover:scale-110"
>
<link.icon size={24} />
</div>
<div>
<div class="font-medium text-foreground">{link.label}</div>
<div class="text-xs text-muted-foreground">{link.description}</div>
</div>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,176 @@
<script lang="ts">
import { useLiveQuery } from '@manacore/local-store/svelte';
import { moodTable } from '$lib/modules/moodlit/collections';
import { moodsStore } from '$lib/modules/moodlit/stores/moods.svelte';
import type { LocalMood } from '$lib/modules/moodlit/types';
import { db } from '$lib/data/database';
import { toast } from 'svelte-sonner';
import { X } from '@manacore/shared-icons';
const moods = useLiveQuery(() =>
db
.table<LocalMood>('moods')
.toArray()
.then((all) => all.filter((m) => !m.deletedAt))
);
let showCreate = $state(false);
let newName = $state('');
let newColors = $state(['#7c3aed', '#a78bfa', '#c4b5fd']);
let newAnimation = $state('gradient');
let activeMood = $state<LocalMood | null>(null);
async function createMood() {
if (!newName) return;
await moodsStore.createMood({
name: newName,
colors: newColors,
animation: newAnimation,
});
toast.success(`"${newName}" erstellt`);
newName = '';
showCreate = false;
}
async function deleteMood(mood: LocalMood) {
if (mood.isDefault) {
toast.error('Standard-Moods konnen nicht geloscht werden');
return;
}
await moodsStore.deleteMood(mood.id);
if (activeMood?.id === mood.id) activeMood = null;
toast.success('Geloscht');
}
function activateMood(mood: LocalMood) {
activeMood = activeMood?.id === mood.id ? null : mood;
}
</script>
<svelte:head>
<title>Moods - Moodlit - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold">Moods</h1>
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-purple-600 px-4 py-2 font-medium text-white hover:bg-purple-700"
>
{showCreate ? 'Schliessen' : '+ Neues Mood'}
</button>
</div>
<!-- Active Mood Display -->
{#if activeMood}
<div
class="mb-6 overflow-hidden rounded-2xl p-8 text-center transition-all duration-1000"
style="background: linear-gradient(135deg, {activeMood.colors.join(', ')})"
>
<h2 class="text-4xl font-bold text-white drop-shadow-lg">{activeMood.name}</h2>
<p class="mt-2 text-white/70">{activeMood.animation}</p>
<button
onclick={() => (activeMood = null)}
class="mt-4 rounded-lg bg-white/20 px-4 py-2 text-sm text-white backdrop-blur hover:bg-white/30"
>Stoppen</button
>
</div>
{/if}
{#if showCreate}
<div class="mb-6 rounded-xl border border-border bg-card p-6">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
<input
type="text"
bind:value={newName}
placeholder="Mein Mood"
class="w-full rounded-lg border border-border bg-input px-4 py-2 text-foreground"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-muted-foreground">Animation</label>
<select
bind:value={newAnimation}
class="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground"
>
<option value="gradient">Gradient</option>
<option value="pulse">Pulse</option>
<option value="wave">Wave</option>
<option value="flicker">Flicker</option>
<option value="aurora">Aurora</option>
</select>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-muted-foreground">Farben</label>
<div class="flex gap-2">
{#each newColors as color, i}
<input
type="color"
bind:value={newColors[i]}
class="h-10 w-14 cursor-pointer rounded border border-border"
/>
{/each}
<button
onclick={() => (newColors = [...newColors, '#ffffff'])}
class="rounded border border-border px-3 text-sm text-muted-foreground hover:bg-muted"
>+</button
>
</div>
</div>
</div>
<div
class="mt-2 h-4 rounded-full"
style="background: linear-gradient(90deg, {newColors.join(', ')})"
></div>
<button
onclick={createMood}
disabled={!newName}
class="mt-4 rounded-lg bg-purple-600 px-6 py-2 font-medium text-white hover:bg-purple-700 disabled:opacity-50"
>Erstellen</button
>
</div>
{/if}
{#if moods.loading}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _}
<div class="h-32 animate-pulse rounded-xl bg-muted"></div>
{/each}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each moods.value ?? [] as mood (mood.id)}
<button
onclick={() => activateMood(mood)}
class="group relative overflow-hidden rounded-xl border-2 p-6 text-left transition-all hover:scale-[1.02] {activeMood?.id ===
mood.id
? 'border-primary shadow-lg shadow-purple-500/20'
: 'border-border hover:border-muted-foreground/30'}"
style="background: linear-gradient(135deg, {mood.colors.map((c) => c + '40').join(', ')})"
>
<h3 class="text-lg font-bold text-foreground">{mood.name}</h3>
<p class="mt-1 text-xs text-muted-foreground">{mood.animation}</p>
<div class="mt-3 flex gap-1">
{#each mood.colors as color}
<div class="h-4 w-4 rounded-full" style="background: {color}"></div>
{/each}
</div>
{#if !mood.isDefault}
<button
onclick={(e) => {
e.stopPropagation();
deleteMood(mood);
}}
class="absolute right-2 top-2 rounded-full p-1 text-muted-foreground opacity-0 hover:bg-muted hover:text-red-400 group-hover:opacity-100"
>
<X size={16} />
</button>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,128 @@
<script lang="ts">
import { useLiveQuery } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import { sequencesStore } from '$lib/modules/moodlit/stores/sequences.svelte';
import type { LocalMood, LocalSequence } from '$lib/modules/moodlit/types';
import { toast } from 'svelte-sonner';
import { Trash } from '@manacore/shared-icons';
const sequences = useLiveQuery(() =>
db
.table<LocalSequence>('sequences')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt))
);
const moods = useLiveQuery(() =>
db
.table<LocalMood>('moods')
.toArray()
.then((all) => all.filter((m) => !m.deletedAt))
);
let newName = $state('');
let newDuration = $state(30);
let showCreate = $state(false);
async function createSequence() {
if (!newName) return;
const allMoods = moods.value ?? [];
await sequencesStore.createSequence({
name: newName,
moodIds: allMoods.slice(0, 3).map((m) => m.id),
duration: newDuration,
});
toast.success(`"${newName}" erstellt`);
newName = '';
showCreate = false;
}
async function deleteSequence(id: string, name: string) {
if (!confirm(`"${name}" loschen?`)) return;
await sequencesStore.deleteSequence(id);
toast.success('Geloscht');
}
function getMoodName(moodId: string): string {
return (moods.value ?? []).find((m) => m.id === moodId)?.name ?? moodId;
}
</script>
<svelte:head>
<title>Sequences - Moodlit - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold">Sequences</h1>
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-purple-600 px-4 py-2 font-medium text-white hover:bg-purple-700"
>
{showCreate ? 'Schliessen' : '+ Neue Sequence'}
</button>
</div>
{#if showCreate}
<div class="mb-6 rounded-xl border border-border bg-card p-5">
<div class="flex gap-3">
<input
type="text"
bind:value={newName}
placeholder="Name"
class="flex-1 rounded-lg border border-border bg-input px-3 py-2 text-foreground"
/>
<input
type="number"
bind:value={newDuration}
min="5"
max="300"
class="w-20 rounded-lg border border-border bg-input px-3 py-2 text-foreground"
/>
<span class="self-center text-sm text-muted-foreground">Sek.</span>
<button
onclick={createSequence}
disabled={!newName}
class="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>Erstellen</button
>
</div>
</div>
{/if}
{#if !sequences.value?.length}
<div class="rounded-xl border-2 border-dashed border-border p-12 text-center">
<p class="text-lg font-medium text-muted-foreground">Keine Sequences</p>
<p class="mt-1 text-sm text-muted-foreground">
Verkette mehrere Moods zu einer automatischen Sequenz.
</p>
</div>
{:else}
<div class="space-y-3">
{#each sequences.value as seq (seq.id)}
<div
class="group rounded-xl border border-border bg-card p-4 hover:border-muted-foreground/30"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{seq.name}</h3>
<div class="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{#each seq.moodIds as moodId}
<span class="rounded bg-purple-500/20 px-2 py-0.5 text-purple-400"
>{getMoodName(moodId)}</span
>
{/each}
<span>· {seq.duration}s pro Mood</span>
</div>
</div>
<button
onclick={() => deleteSequence(seq.id, seq.name)}
class="rounded p-1 text-muted-foreground opacity-0 hover:text-red-400 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { setContext } from 'svelte';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import {
toFavorite,
toQuoteList,
type Favorite,
type QuoteList,
} from '$lib/modules/zitare/queries';
import type { LocalFavorite, LocalQuoteList } from '$lib/modules/zitare/types';
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
import { zitareSettings } from '$lib/modules/zitare/stores/settings.svelte';
let { children } = $props();
// Initialize zitare stores
quotesStore.initialize();
zitareSettings.initialize();
// Provide reactive favorites & lists contexts for child routes
const allFavorites = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalFavorite>('zitareFavorites').toArray();
return locals.filter((f) => !f.deletedAt).map(toFavorite);
}, [] as Favorite[]);
setContext('favorites', allFavorites);
const allLists = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalQuoteList>('zitareLists').toArray();
return locals.filter((l) => !l.deletedAt).map(toQuoteList);
}, [] as QuoteList[]);
setContext('lists', allLists);
</script>
{@render children()}

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
import { zitareSettings } from '$lib/modules/zitare/stores/settings.svelte';
import { ZitareEvents } from '@manacore/shared-utils/analytics';
import QuoteCard from '$lib/modules/zitare/components/QuoteCard.svelte';
import { ArrowsClockwise } from '@manacore/shared-icons';
let isRefreshing = $state(false);
async function loadNewQuote() {
isRefreshing = true;
quotesStore.loadRandomQuote();
ZitareEvents.randomQuoteLoaded();
// Small delay for visual feedback
await new Promise((r) => setTimeout(r, 300));
isRefreshing = false;
}
</script>
<svelte:head>
<title>Zitare - {$_('home.dailyQuote')}</title>
</svelte:head>
<div class="max-w-2xl mx-auto">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-foreground mb-2">{$_('home.dailyQuote')}</h1>
<p class="text-foreground-secondary">{$_('app.tagline')}</p>
</div>
<!-- Daily Quote Card -->
{#if quotesStore.currentQuote}
<div class="mb-8 transition-all duration-300 {isRefreshing ? 'opacity-50 scale-95' : ''}">
<QuoteCard
quote={quotesStore.currentQuote}
size="large"
showCategory={zitareSettings.showCategory}
showSource={zitareSettings.showSource}
/>
</div>
{/if}
<!-- New Quote Button -->
<div class="text-center">
<button
onclick={loadNewQuote}
disabled={isRefreshing}
class="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors disabled:opacity-50"
>
<ArrowsClockwise size={20} class={isRefreshing ? 'animate-spin' : ''} />
{$_('home.newQuote')}
</button>
</div>
<!-- Quote Stats -->
<div class="mt-12 text-center">
<p class="text-sm text-foreground-muted">
{quotesStore.totalCount} Zitate in 10 Kategorien
</p>
</div>
</div>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { ZitareEvents } from '@manacore/shared-utils/analytics';
import { CATEGORIES, getQuotesByCategory, type Category } from '@zitare/content';
// Category data with icons and gradients
const categoryData: Record<
Category,
{ icon: string; gradient: string; labelKey: string; count: number }
> = {
weisheit: {
icon: '🧠',
gradient: 'from-violet-500 to-purple-600',
labelKey: 'categories.wisdom',
count: getQuotesByCategory('weisheit').length,
},
motivation: {
icon: '🔥',
gradient: 'from-orange-500 to-red-500',
labelKey: 'categories.motivation',
count: getQuotesByCategory('motivation').length,
},
liebe: {
icon: '❤️',
gradient: 'from-pink-500 to-rose-500',
labelKey: 'categories.love',
count: getQuotesByCategory('liebe').length,
},
leben: {
icon: '🌱',
gradient: 'from-emerald-500 to-cyan-500',
labelKey: 'categories.life',
count: getQuotesByCategory('leben').length,
},
erfolg: {
icon: '🏆',
gradient: 'from-indigo-500 to-purple-500',
labelKey: 'categories.success',
count: getQuotesByCategory('erfolg').length,
},
glueck: {
icon: '☀️',
gradient: 'from-yellow-400 to-orange-500',
labelKey: 'categories.happiness',
count: getQuotesByCategory('glueck').length,
},
freundschaft: {
icon: '🤝',
gradient: 'from-blue-500 to-indigo-500',
labelKey: 'categories.friendship',
count: getQuotesByCategory('freundschaft').length,
},
mut: {
icon: '🦁',
gradient: 'from-red-500 to-red-700',
labelKey: 'categories.courage',
count: getQuotesByCategory('mut').length,
},
hoffnung: {
icon: '🌈',
gradient: 'from-teal-500 to-sky-500',
labelKey: 'categories.hope',
count: getQuotesByCategory('hoffnung').length,
},
natur: {
icon: '🌿',
gradient: 'from-green-500 to-emerald-500',
labelKey: 'categories.nature',
count: getQuotesByCategory('natur').length,
},
};
</script>
<svelte:head>
<title>Zitare - {$_('categories.title')}</title>
</svelte:head>
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-8">{$_('categories.title')}</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each CATEGORIES as category}
{@const data = categoryData[category]}
<button
onclick={() => {
ZitareEvents.categoryViewed(category);
goto(`/zitare/category/${category}`);
}}
class="group p-6 rounded-2xl bg-gradient-to-br {data.gradient} text-white text-left transition-transform hover:scale-105 hover:shadow-xl"
>
<div class="text-4xl mb-3">{data.icon}</div>
<h2 class="text-xl font-semibold mb-1">{$_(data.labelKey)}</h2>
<p class="text-white/80 text-sm">
{$_('categories.quotes', { values: { count: data.count } })}
</p>
</button>
{/each}
</div>
</div>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getQuotesByCategory, CATEGORIES, type Category, type Quote } from '@zitare/content';
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
import { zitareSettings } from '$lib/modules/zitare/stores/settings.svelte';
import QuoteCard from '$lib/modules/zitare/components/QuoteCard.svelte';
import { CaretLeft, MagnifyingGlass } from '@manacore/shared-icons';
// Get category from URL
let category = $derived($page.params.category as Category);
// Validate category
let isValidCategory = $derived(CATEGORIES.includes(category));
// Get quotes for this category
let quotes = $derived(isValidCategory ? getQuotesByCategory(category) : []);
// Search & sort state
let searchTerm = $state('');
let sortBy = $state<'default' | 'author'>('default');
// Filtered and sorted quotes
let displayedQuotes = $derived<Quote[]>(() => {
let filtered = quotes;
// Filter by search
if (searchTerm.length >= 2) {
const lower = searchTerm.toLowerCase();
filtered = filtered.filter(
(q) =>
quotesStore.getText(q).toLowerCase().includes(lower) ||
q.author.toLowerCase().includes(lower)
);
}
// Sort
if (sortBy === 'author') {
return [...filtered].sort((a, b) => a.author.localeCompare(b.author));
}
return filtered;
});
// Category labels
const categoryLabels: Record<Category, string> = {
weisheit: 'categories.wisdom',
motivation: 'categories.motivation',
liebe: 'categories.love',
leben: 'categories.life',
erfolg: 'categories.success',
glueck: 'categories.happiness',
freundschaft: 'categories.friendship',
mut: 'categories.courage',
hoffnung: 'categories.hope',
natur: 'categories.nature',
};
</script>
<svelte:head>
<title
>Zitare - {isValidCategory ? $_(categoryLabels[category]) : $_('categories.notFound')}</title
>
</svelte:head>
<div class="max-w-3xl mx-auto">
<!-- Back button -->
<button
onclick={() => goto('/zitare/categories')}
class="flex items-center gap-2 text-foreground-secondary hover:text-foreground mb-6 transition-colors"
>
<CaretLeft size={20} />
{$_('categories.title')}
</button>
{#if isValidCategory}
<h1 class="text-3xl font-bold text-foreground mb-2">{$_(categoryLabels[category])}</h1>
<p class="text-foreground-secondary mb-6">
{$_('categories.quotes', { values: { count: quotes.length } })}
</p>
<!-- Search & Sort Bar -->
<div class="flex gap-3 mb-8">
<div class="relative flex-1">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-muted">
<MagnifyingGlass size={16} />
</div>
<input
type="text"
placeholder={$_('categories.searchInCategory')}
bind:value={searchTerm}
class="w-full pl-10 pr-4 py-2.5 rounded-xl bg-surface-elevated border border-border text-foreground text-sm focus:outline-none focus:border-primary transition-colors"
/>
</div>
<select
bind:value={sortBy}
class="px-3 py-2.5 rounded-xl bg-surface-elevated border border-border text-foreground text-sm"
>
<option value="default">{$_('categories.sortByDefault')}</option>
<option value="author">{$_('categories.sortByAuthor')}</option>
</select>
</div>
{#if displayedQuotes.length === 0 && searchTerm.length >= 2}
<div class="text-center py-12">
<p class="text-foreground-secondary">{$_('search.noResults')}</p>
</div>
{:else}
<div class="space-y-6">
{#each displayedQuotes as quote (quote.id)}
<QuoteCard {quote} showSource={zitareSettings.showSource} />
{/each}
</div>
{/if}
{:else}
<div class="text-center py-12">
<p class="text-foreground-secondary">{$_('categories.notFound')}</p>
<button onclick={() => goto('/zitare/categories')} class="mt-4 text-primary hover:underline">
{$_('categories.backToCategories')}
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,137 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/modules/zitare/stores/favorites.svelte';
import { type Favorite } from '$lib/modules/zitare/queries';
import { getQuoteById, getQuoteText, type Quote } from '@zitare/content';
import { zitareSettings } from '$lib/modules/zitare/stores/settings.svelte';
import QuoteCard from '$lib/modules/zitare/components/QuoteCard.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { Heart, User } from '@manacore/shared-icons';
const allFavorites: { readonly value: Favorite[] } = getContext('favorites');
// Get favorite quotes
let favoriteQuotes = $derived(
allFavorites.value
.map((f) => getQuoteById(f.quoteId))
.filter((q): q is NonNullable<typeof q> => q !== undefined)
);
// Context menu state
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuQuote = $state<Quote | null>(null);
function handleContextMenu(e: MouseEvent, quote: Quote) {
e.preventDefault();
e.stopPropagation();
contextMenuX = e.clientX;
contextMenuY = e.clientY;
contextMenuQuote = quote;
contextMenuVisible = true;
}
function getContextMenuItems(): ContextMenuItem[] {
if (!contextMenuQuote) return [];
const quote = contextMenuQuote;
return [
{
id: 'remove-favorite',
label: $_('favorites.removeFromFavorites'),
variant: 'danger',
action: () => favoritesStore.toggle(quote.id, allFavorites.value),
},
{ id: 'divider-1', label: '', type: 'divider' },
{
id: 'copy',
label: $_('favorites.copyQuote'),
action: () => {
const text = getQuoteText(quote);
navigator.clipboard.writeText(`"${text}" — ${quote.author}`);
},
},
{
id: 'share',
label: $_('favorites.share'),
action: async () => {
const text = `"${getQuoteText(quote)}" — ${quote.author}`;
if (navigator.share) {
try {
await navigator.share({ text });
} catch {
// User cancelled or share failed, ignore
}
} else {
await navigator.clipboard.writeText(text);
}
},
},
];
}
</script>
<svelte:head>
<title>Zitare - {$_('favorites.title')}</title>
</svelte:head>
<div class="max-w-3xl mx-auto">
<div class="flex items-center gap-3 mb-8">
<h1 class="text-3xl font-bold text-foreground">{$_('favorites.title')}</h1>
{#if authStore.isAuthenticated && favoriteQuotes.length > 0}
<span class="px-2.5 py-0.5 rounded-full text-sm font-medium bg-primary/10 text-primary">
{favoriteQuotes.length}
</span>
{/if}
</div>
{#if !authStore.isAuthenticated}
<!-- Not logged in -->
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<User size={20} class="mx-auto text-foreground-muted mb-4" />
<p class="text-foreground-secondary mb-4">{$_('favorites.loginPrompt')}</p>
<button
onclick={() => goto('/login')}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
{$_('auth.login')}
</button>
</div>
{:else if favoriteQuotes.length === 0}
<!-- Empty state -->
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<Heart size={20} class="mx-auto text-foreground-muted mb-4" />
<p class="text-lg font-medium text-foreground mb-2">{$_('favorites.empty')}</p>
<p class="text-foreground-secondary">{$_('favorites.emptyDescription')}</p>
</div>
{:else}
<!-- Favorites list -->
<div class="space-y-6">
{#each favoriteQuotes as quote (quote.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div oncontextmenu={(e) => handleContextMenu(e, quote)}>
<QuoteCard
{quote}
showCategory={zitareSettings.showCategory}
showSource={zitareSettings.showSource}
/>
</div>
{/each}
</div>
{/if}
</div>
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems()}
onClose={() => {
contextMenuVisible = false;
contextMenuQuote = null;
}}
/>

View file

@ -0,0 +1,210 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { listsStore } from '$lib/modules/zitare/stores/lists.svelte';
import { type QuoteList } from '$lib/modules/zitare/queries';
import { ZitareEvents } from '@manacore/shared-utils/analytics';
import { Plus, Trash, X, User, Archive } from '@manacore/shared-icons';
const allLists: { readonly value: QuoteList[] } = getContext('lists');
let saving = $state(false);
let deletingId = $state<string | null>(null);
let showCreateModal = $state(false);
let newListName = $state('');
let newListDescription = $state('');
async function createList() {
if (!newListName.trim() || saving) return;
saving = true;
try {
const created = await listsStore.createList(
newListName.trim(),
newListDescription.trim() || undefined
);
if (created) {
ZitareEvents.listCreated();
showCreateModal = false;
newListName = '';
newListDescription = '';
} else {
toast.error($_('common.error'));
}
} catch (error) {
console.error('Failed to create list:', error);
toast.error($_('common.error'));
} finally {
saving = false;
}
}
async function deleteList(listId: string) {
if (deletingId || !confirm($_('lists.confirmDelete'))) return;
deletingId = listId;
try {
const success = await listsStore.deleteList(listId);
if (success) {
ZitareEvents.listDeleted();
} else {
toast.error($_('lists.detail.toast.deleteError'));
}
} catch (error) {
console.error('Failed to delete list:', error);
toast.error($_('lists.detail.toast.deleteError'));
} finally {
deletingId = null;
}
}
</script>
<svelte:head>
<title>Zitare - {$_('lists.title')}</title>
</svelte:head>
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-foreground">{$_('lists.title')}</h1>
{#if authStore.isAuthenticated}
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
<Plus size={20} weight="bold" />
{$_('lists.create')}
</button>
{/if}
</div>
{#if !authStore.isAuthenticated}
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<div class="w-16 h-16 mx-auto text-foreground-muted mb-4 flex items-center justify-center">
<User size={64} />
</div>
<p class="text-foreground-secondary mb-4">{$_('lists.loginPrompt')}</p>
<button
onclick={() => goto('/login')}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
{$_('auth.login')}
</button>
</div>
{:else if allLists.value.length === 0}
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<div class="w-16 h-16 mx-auto text-foreground-muted mb-4 flex items-center justify-center">
<Archive size={64} />
</div>
<p class="text-lg font-medium text-foreground mb-2">{$_('lists.empty')}</p>
<p class="text-foreground-secondary">{$_('lists.emptyDescription')}</p>
</div>
{:else}
<div class="grid gap-4">
{#each allLists.value as list (list.id)}
<a
href="/zitare/lists/{list.id}"
class="block p-6 bg-surface-elevated rounded-2xl hover:shadow-lg transition-all group"
>
<div class="flex items-start justify-between">
<div>
<h3
class="text-lg font-semibold text-foreground group-hover:text-primary transition-colors"
>
{list.name}
</h3>
{#if list.description}
<p class="text-foreground-secondary mt-1">{list.description}</p>
{/if}
<p class="text-sm text-foreground-muted mt-2">
{$_('lists.quoteCount', { values: { count: list.quoteIds.length } })}
</p>
</div>
<button
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteList(list.id);
}}
disabled={deletingId === list.id}
class="p-2 text-foreground-muted hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
>
{#if deletingId === list.id}
<div
class="w-5 h-5 border-2 border-red-400 border-t-transparent rounded-full animate-spin"
></div>
{:else}
<Trash size={20} />
{/if}
</button>
</div>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-surface-elevated rounded-2xl w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-6 border-b border-border">
<h3 class="text-xl font-semibold text-foreground">{$_('lists.createModal.title')}</h3>
<button
onclick={() => (showCreateModal = false)}
class="p-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<X size={20} />
</button>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-2"
>{$_('lists.nameLabel')} *</label
>
<input
type="text"
bind:value={newListName}
placeholder={$_('lists.createModal.namePlaceholder')}
maxlength="50"
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground focus:outline-none focus:border-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-2"
>{$_('lists.descriptionLabel')}</label
>
<textarea
bind:value={newListDescription}
placeholder={$_('lists.createModal.descriptionPlaceholder')}
maxlength="200"
rows="3"
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground focus:outline-none focus:border-primary resize-none"
></textarea>
</div>
</div>
<div class="flex justify-end gap-3 p-6 border-t border-border">
<button
onclick={() => (showCreateModal = false)}
class="px-4 py-2 text-foreground-secondary hover:text-foreground transition-colors"
>
{$_('common.cancel')}
</button>
<button
onclick={createList}
disabled={!newListName.trim() || saving}
class="px-6 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if saving}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
{/if}
{$_('lists.createModal.submit')}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,958 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _, locale } from 'svelte-i18n';
import { getContext } from 'svelte';
import { listsStore } from '$lib/modules/zitare/stores/lists.svelte';
import { findListById, type QuoteList } from '$lib/modules/zitare/queries';
import { authStore } from '$lib/stores/auth.svelte';
import { quotesStore } from '$lib/modules/zitare/stores/quotes.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { QUOTES, type Quote } from '@zitare/content';
import QuoteCard from '$lib/modules/zitare/components/QuoteCard.svelte';
import {
MagnifyingGlass,
X,
PencilSimple,
Plus,
ListBullets,
Trash,
} from '@manacore/shared-icons';
const allQuotes = QUOTES;
const allLists: { readonly value: QuoteList[] } = getContext('lists');
let isSaving = $state(false);
let isAdding = $state(false);
let removingQuoteId = $state<string | null>(null);
let searchTerm = $state('');
let isSearchOpen = $state(false);
let showEditModal = $state(false);
let showAddQuotesModal = $state(false);
let editName = $state('');
let editDescription = $state('');
let selectedQuoteIds = $state<Set<string>>(new Set());
// Reactive list from liveQuery context
let list = $derived<QuoteList | undefined>(findListById(allLists.value, $page.params.id));
// Get quotes in this list
let listQuotes = $derived<Quote[]>(
list ? allQuotes.filter((quote: Quote) => list!.quoteIds.includes(quote.id)) : []
);
// Filter quotes by search
let filteredQuotes = $derived<Quote[]>(
listQuotes.filter(
(quote: Quote) =>
quotesStore.getText(quote).toLowerCase().includes(searchTerm.toLowerCase()) ||
quote.author.toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Get available quotes (not in this list)
let availableQuotes = $derived<Quote[]>(
allQuotes.filter((quote: Quote) => !list?.quoteIds.includes(quote.id))
);
function toggleSearch() {
isSearchOpen = !isSearchOpen;
if (!isSearchOpen) {
searchTerm = '';
}
}
function openEditModal() {
if (list) {
editName = list.name;
editDescription = list.description || '';
showEditModal = true;
}
}
function closeEditModal() {
showEditModal = false;
}
async function handleUpdateList() {
if (!list || !editName.trim() || isSaving) return;
isSaving = true;
try {
const updated = await listsStore.updateList(list.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
});
if (updated) {
toast.success($_('lists.detail.toast.updated'));
closeEditModal();
} else {
toast.error($_('lists.detail.toast.updateError'));
}
} finally {
isSaving = false;
}
}
async function handleDeleteList() {
if (list && confirm($_('lists.confirmDelete'))) {
const success = await listsStore.deleteList(list.id);
if (success) {
toast.info($_('lists.detail.toast.deleted'));
goto('/zitare/lists');
} else {
toast.error($_('lists.detail.toast.deleteError'));
}
}
}
function openAddQuotesModal() {
selectedQuoteIds = new Set();
showAddQuotesModal = true;
}
function closeAddQuotesModal() {
showAddQuotesModal = false;
selectedQuoteIds = new Set();
}
function toggleQuoteSelection(quoteId: string) {
if (selectedQuoteIds.has(quoteId)) {
selectedQuoteIds.delete(quoteId);
} else {
selectedQuoteIds.add(quoteId);
}
selectedQuoteIds = new Set(selectedQuoteIds);
}
async function handleAddQuotes() {
if (!list || isAdding) return;
isAdding = true;
try {
let successCount = 0;
for (const quoteId of selectedQuoteIds) {
const success = await listsStore.addQuoteToList(list.id, quoteId);
if (success) successCount++;
}
if (successCount > 0) {
toast.success($_('lists.detail.toast.quotesAdded', { values: { count: successCount } }));
}
closeAddQuotesModal();
} finally {
isAdding = false;
}
}
async function handleRemoveQuote(quoteId: string) {
if (!list || removingQuoteId || !confirm($_('lists.detail.removeConfirm'))) return;
removingQuoteId = quoteId;
try {
const success = await listsStore.removeQuoteFromList(list.id, quoteId);
if (success) {
toast.info($_('lists.detail.toast.quoteRemoved'));
} else {
toast.error($_('lists.detail.toast.removeError'));
}
} finally {
removingQuoteId = null;
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString($locale || 'de', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>{list?.name || $_('common.list')} - Zitare</title>
</svelte:head>
{#if !list}
<div class="error-state">
<h2>{$_('lists.detail.notFound')}</h2>
<p>{$_('lists.detail.notFoundDescription')}</p>
<a href="/zitare/lists" class="cta-button">{$_('lists.detail.backToLists')}</a>
</div>
{:else}
<div class="list-detail-page">
<!-- Header -->
<div class="header-container">
<div class="breadcrumb">
<a href="/zitare/lists">{$_('lists.detail.breadcrumb')}</a>
<span class="separator">/</span>
<span>{list.name}</span>
</div>
<div class="header-row">
<div class="header-content">
<h2>{list.name}</h2>
{#if list.description}
<p class="description">{list.description}</p>
{/if}
<div class="meta">
<span>{$_('lists.quoteCount', { values: { count: listQuotes.length } })}</span>
<span class="separator"></span>
<span
>{$_('lists.detail.lastEdited', {
values: { date: formatDate(list.updatedAt) },
})}</span
>
</div>
</div>
<div class="header-actions">
{#if listQuotes.length > 0}
<button class="icon-btn" onclick={toggleSearch} aria-label={$_('common.search')}>
{#if isSearchOpen}
<X size={20} />
{:else}
<MagnifyingGlass size={20} />
{/if}
</button>
{/if}
<button
class="icon-btn"
onclick={openEditModal}
aria-label={$_('lists.detail.editModal.title')}
>
<PencilSimple size={20} />
</button>
<button
class="icon-btn add-btn"
onclick={openAddQuotesModal}
aria-label={$_('lists.detail.addQuotes')}
>
<Plus size={20} weight="bold" />
</button>
</div>
</div>
{#if isSearchOpen}
<div class="search-bar">
<input
type="text"
placeholder={$_('lists.detail.searchPlaceholder')}
bind:value={searchTerm}
class="search"
/>
</div>
{/if}
</div>
<!-- Quotes Grid -->
{#if listQuotes.length === 0}
<div class="empty-state">
<div class="empty-icon">
<ListBullets size={64} />
</div>
<h3>{$_('lists.detail.emptyTitle')}</h3>
<p>{$_('lists.detail.emptyDescription')}</p>
<button class="cta-button" onclick={openAddQuotesModal}>
<Plus size={20} weight="bold" />
{$_('lists.detail.addQuotes')}
</button>
</div>
{:else if filteredQuotes.length === 0}
<div class="empty-state">
<div class="empty-icon">
<MagnifyingGlass size={64} />
</div>
<h3>{$_('lists.detail.noSearchResults')}</h3>
<p>{$_('lists.detail.noSearchResultsDescription')}</p>
</div>
{:else}
<div class="quotes-grid">
{#each filteredQuotes as quote (quote.id)}
<div class="quote-wrapper">
<QuoteCard {quote} />
<button
class="remove-btn"
onclick={() => handleRemoveQuote(quote.id)}
disabled={removingQuoteId === quote.id}
aria-label={$_('lists.detail.remove')}
>
{#if removingQuoteId === quote.id}
<div
class="w-4 h-4 border-2 border-red-400 border-t-transparent rounded-full animate-spin"
></div>
{:else}
<X size={16} />
{/if}
{$_('lists.detail.remove')}
</button>
</div>
{/each}
</div>
{/if}
{#if isSearchOpen && filteredQuotes.length > 0}
<div class="floating-results">
{$_('lists.detail.floatingResults', {
values: { filtered: filteredQuotes.length, total: listQuotes.length },
})}
</div>
{/if}
</div>
{/if}
<!-- Edit List Modal -->
{#if showEditModal}
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div class="modal-header">
<h3>{$_('lists.detail.editModal.title')}</h3>
<button class="close-btn" onclick={closeEditModal} aria-label={$_('common.close')}>
<X size={24} />
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="edit-name">{$_('lists.nameLabel')} *</label>
<input
id="edit-name"
type="text"
bind:value={editName}
class="form-input"
maxlength="50"
/>
</div>
<div class="form-group">
<label for="edit-description">{$_('lists.descriptionLabel')}</label>
<textarea
id="edit-description"
bind:value={editDescription}
class="form-textarea"
rows="3"
maxlength="200"
></textarea>
</div>
<button class="danger-btn" onclick={handleDeleteList}>
<Trash size={20} />
{$_('lists.detail.editModal.deleteList')}
</button>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick={closeEditModal}>{$_('common.cancel')}</button>
<button
class="btn btn-primary"
onclick={handleUpdateList}
disabled={!editName.trim() || isSaving}
>
{#if isSaving}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin inline-block mr-1"
></div>
{/if}
{$_('common.save')}
</button>
</div>
</div>
</div>
{/if}
<!-- Add Quotes Modal -->
{#if showAddQuotesModal}
<div class="modal-overlay" onclick={closeAddQuotesModal} role="presentation">
<div
class="modal modal-large"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div class="modal-header">
<h3>{$_('lists.detail.addModal.title')}</h3>
<button class="close-btn" onclick={closeAddQuotesModal} aria-label={$_('common.close')}>
<X size={24} />
</button>
</div>
<div class="modal-body quote-selection">
{#each availableQuotes.slice(0, 50) as quote (quote.id)}
<label class="quote-option">
<input
type="checkbox"
checked={selectedQuoteIds.has(quote.id)}
onchange={() => toggleQuoteSelection(quote.id)}
/>
<div class="quote-preview">
<p class="quote-text">"{quotesStore.getText(quote)}"</p>
<p class="quote-author">--- {quote.author}</p>
</div>
</label>
{/each}
</div>
<div class="modal-footer">
<div class="selected-count">
{$_('lists.detail.addModal.selected', { values: { count: selectedQuoteIds.size } })}
</div>
<div class="footer-actions">
<button class="btn btn-secondary" onclick={closeAddQuotesModal}
>{$_('common.cancel')}</button
>
<button
class="btn btn-primary"
onclick={handleAddQuotes}
disabled={selectedQuoteIds.size === 0 || isAdding}
>
{#if isAdding}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin inline-block mr-1"
></div>
{/if}
{$_('lists.detail.addModal.submit', { values: { count: selectedQuoteIds.size } })}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.list-detail-page {
max-width: 1200px;
margin: 0 auto;
padding-bottom: var(--spacing-2xl);
}
.loading-state,
.error-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(var(--color-border));
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--spacing-md);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.header-container {
max-width: 700px;
margin: 0 auto var(--spacing-xl);
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.breadcrumb a {
color: rgb(var(--color-primary));
text-decoration: none;
transition: opacity var(--transition-fast);
}
.breadcrumb a:hover {
opacity: 0.8;
}
.breadcrumb .separator {
color: rgb(var(--color-text-tertiary));
}
.header-row {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.header-content {
flex: 1;
}
h2 {
font-size: 2rem;
margin: 0 0 var(--spacing-xs) 0;
color: rgb(var(--color-text-primary));
}
.description {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0 0 var(--spacing-sm) 0;
line-height: 1.5;
}
.meta {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 0.875rem;
color: rgb(var(--color-text-tertiary));
}
.meta .separator {
color: rgb(var(--color-border));
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
align-items: flex-start;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 1px solid rgb(var(--color-border));
cursor: pointer;
transition: all var(--transition-base);
}
.icon-btn:hover {
background: rgb(var(--color-background));
transform: scale(1.05);
}
.icon-btn.add-btn {
background: rgb(var(--color-primary));
color: white;
border: none;
}
.icon-btn.add-btn:hover {
opacity: 0.9;
}
.search-bar {
padding: var(--spacing-md);
background: rgb(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid rgb(var(--color-border));
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-background));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
}
.search:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.quotes-grid {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
max-width: 700px;
margin: 0 auto;
}
.quote-wrapper {
position: relative;
}
.remove-btn {
display: none;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
position: absolute;
top: var(--spacing-sm);
right: var(--spacing-sm);
}
.quote-wrapper:hover .remove-btn {
display: flex;
}
.remove-btn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
/* Empty State */
.empty-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.empty-icon {
margin: 0 auto var(--spacing-lg);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-sm) 0;
}
.empty-state p {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0 0 var(--spacing-xl) 0;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-xl);
background: rgb(var(--color-primary));
color: white;
border: none;
border-radius: var(--radius-full);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
text-decoration: none;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* Floating Results */
.floating-results {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: var(--spacing-sm) var(--spacing-lg);
background: rgba(var(--color-surface), 0.95);
backdrop-filter: blur(10px);
border-radius: var(--radius-full);
border: 1px solid rgba(var(--color-border), 0.5);
box-shadow: var(--shadow-lg);
color: rgb(var(--color-text-secondary));
font-size: 0.875rem;
font-weight: 500;
z-index: 20;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: var(--spacing-lg);
}
.modal {
background: rgb(var(--color-surface-elevated));
border-radius: var(--radius-xl);
max-width: 500px;
width: 100%;
box-shadow: var(--shadow-xl);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-large {
max-width: 700px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid rgb(var(--color-border));
flex-shrink: 0;
}
.modal-header h3 {
font-size: 1.25rem;
margin: 0;
color: rgb(var(--color-text-primary));
}
.close-btn {
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgb(var(--color-text-secondary));
transition: all var(--transition-fast);
border-radius: var(--radius-sm);
}
.close-btn:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.modal-body {
padding: var(--spacing-lg);
overflow-y: auto;
flex: 1;
}
.quote-selection {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.quote-option {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: rgb(var(--color-surface));
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.quote-option:hover {
border-color: rgb(var(--color-primary));
background: rgb(var(--color-background));
}
.quote-option input[type='checkbox'] {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.quote-preview {
flex: 1;
}
.quote-text {
font-size: 0.9375rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-xs) 0;
line-height: 1.5;
}
.quote-author {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
margin: 0;
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
margin-bottom: var(--spacing-xs);
}
.form-input,
.form-textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-background));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
font-family: inherit;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.form-textarea {
resize: vertical;
}
.danger-btn {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: var(--spacing-xl);
width: 100%;
justify-content: center;
}
.danger-btn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border-top: 1px solid rgb(var(--color-border));
flex-shrink: 0;
}
.selected-count {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.footer-actions {
display: flex;
gap: var(--spacing-md);
}
.btn {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.9375rem;
cursor: pointer;
transition: all var(--transition-base);
border: none;
}
.btn-secondary {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 1px solid rgb(var(--color-border));
}
.btn-secondary:hover {
background: rgb(var(--color-background));
}
.btn-primary {
background: rgb(var(--color-primary));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.list-detail-page {
padding-bottom: var(--spacing-xl);
}
.header-container {
max-width: 100%;
}
h2 {
font-size: 1.5rem;
}
.quotes-grid {
max-width: 100%;
}
.remove-btn {
display: flex;
position: static;
width: 100%;
margin-top: var(--spacing-sm);
}
.floating-results {
bottom: 5rem;
}
.modal {
margin: var(--spacing-sm);
}
.modal-footer {
flex-direction: column;
align-items: stretch;
}
.footer-actions {
width: 100%;
}
.footer-actions .btn {
flex: 1;
}
}
</style>