mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
aadd1c7538
commit
e449172932
66 changed files with 9883 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
|
|
|||
43
apps/manacore/apps/web/src/lib/modules/clock/collections.ts
Normal file
43
apps/manacore/apps/web/src/lib/modules/clock/collections.ts
Normal 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[],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
26
apps/manacore/apps/web/src/lib/modules/clock/index.ts
Normal file
26
apps/manacore/apps/web/src/lib/modules/clock/index.ts
Normal 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';
|
||||
124
apps/manacore/apps/web/src/lib/modules/clock/queries.ts
Normal file
124
apps/manacore/apps/web/src/lib/modules/clock/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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')}`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
31
apps/manacore/apps/web/src/lib/modules/clock/types.ts
Normal file
31
apps/manacore/apps/web/src/lib/modules/clock/types.ts
Normal 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;
|
||||
}
|
||||
109
apps/manacore/apps/web/src/lib/modules/inventar/collections.ts
Normal file
109
apps/manacore/apps/web/src/lib/modules/inventar/collections.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
324
apps/manacore/apps/web/src/lib/modules/inventar/queries.ts
Normal file
324
apps/manacore/apps/web/src/lib/modules/inventar/queries.ts
Normal 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);
|
||||
}
|
||||
68
apps/manacore/apps/web/src/lib/modules/inventar/types.ts
Normal file
68
apps/manacore/apps/web/src/lib/modules/inventar/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
198
apps/manacore/apps/web/src/lib/modules/moodlit/default-moods.ts
Normal file
198
apps/manacore/apps/web/src/lib/modules/moodlit/default-moods.ts
Normal 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(', ')})`;
|
||||
}
|
||||
20
apps/manacore/apps/web/src/lib/modules/moodlit/index.ts
Normal file
20
apps/manacore/apps/web/src/lib/modules/moodlit/index.ts
Normal 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';
|
||||
40
apps/manacore/apps/web/src/lib/modules/moodlit/queries.ts
Normal file
40
apps/manacore/apps/web/src/lib/modules/moodlit/queries.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
109
apps/manacore/apps/web/src/lib/modules/moodlit/types.ts
Normal file
109
apps/manacore/apps/web/src/lib/modules/moodlit/types.ts
Normal 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' },
|
||||
];
|
||||
113
apps/manacore/apps/web/src/lib/modules/skilltree/collections.ts
Normal file
113
apps/manacore/apps/web/src/lib/modules/skilltree/collections.ts
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
59
apps/manacore/apps/web/src/lib/modules/skilltree/index.ts
Normal file
59
apps/manacore/apps/web/src/lib/modules/skilltree/index.ts
Normal 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';
|
||||
181
apps/manacore/apps/web/src/lib/modules/skilltree/queries.ts
Normal file
181
apps/manacore/apps/web/src/lib/modules/skilltree/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
588
apps/manacore/apps/web/src/lib/modules/skilltree/types.ts
Normal file
588
apps/manacore/apps/web/src/lib/modules/skilltree/types.ts
Normal 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 },
|
||||
},
|
||||
];
|
||||
37
apps/manacore/apps/web/src/lib/modules/zitare/collections.ts
Normal file
37
apps/manacore/apps/web/src/lib/modules/zitare/collections.ts
Normal 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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
21
apps/manacore/apps/web/src/lib/modules/zitare/index.ts
Normal file
21
apps/manacore/apps/web/src/lib/modules/zitare/index.ts
Normal 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';
|
||||
83
apps/manacore/apps/web/src/lib/modules/zitare/queries.ts
Normal file
83
apps/manacore/apps/web/src/lib/modules/zitare/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
15
apps/manacore/apps/web/src/lib/modules/zitare/types.ts
Normal file
15
apps/manacore/apps/web/src/lib/modules/zitare/types.ts
Normal 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[];
|
||||
}
|
||||
19
apps/manacore/apps/web/src/routes/(app)/clock/+layout.svelte
Normal file
19
apps/manacore/apps/web/src/routes/(app)/clock/+layout.svelte
Normal 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()}
|
||||
93
apps/manacore/apps/web/src/routes/(app)/clock/+page.svelte
Normal file
93
apps/manacore/apps/web/src/routes/(app)/clock/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
67
apps/manacore/apps/web/src/routes/(app)/moodlit/+page.svelte
Normal file
67
apps/manacore/apps/web/src/routes/(app)/moodlit/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()}
|
||||
62
apps/manacore/apps/web/src/routes/(app)/zitare/+page.svelte
Normal file
62
apps/manacore/apps/web/src/routes/(app)/zitare/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue