From 9b614cdfbc816386752bbf60e1cd4d99b3df72ab Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 1 Apr 2026 20:48:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(manacore):=20migrate=20contacts,=20todo,?= =?UTF-8?q?=20calendar,=20picture,=20chat,=20mukke,=20memoro=20=E2=80=94?= =?UTF-8?q?=20Phase=202=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 25 modules now migrated to the unified same-origin app (25/25): - Contacts: contact list with alphabet grouping, detail/edit (3 routes) - Todo: task management with inbox/today/upcoming views, subtasks (2 routes) - Calendar: week/month/agenda views, event CRUD (4 routes) - Picture: gallery with favorites, AI generation, moodboards (6 routes) - Chat: conversation list, AI chat, templates, archive (5 routes) - Mukke: music library, playlists, projects, audio player (6 routes) - Memoro: voice memos, transcripts, memories, tags (5 routes) Phase 2 of the unified app migration is now complete. Total: 26 modules, ~120 routes, 250+ files. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/modules/calendar/collections.ts | 81 +++ .../web/src/lib/modules/calendar/index.ts | 26 + .../web/src/lib/modules/calendar/queries.ts | 165 ++++++ .../calendar/stores/calendars.svelte.ts | 124 +++++ .../modules/calendar/stores/events.svelte.ts | 167 ++++++ .../modules/calendar/stores/view.svelte.ts | 118 +++++ .../web/src/lib/modules/calendar/types.ts | 56 ++ .../web/src/lib/modules/chat/collections.ts | 41 ++ .../apps/web/src/lib/modules/chat/index.ts | 31 ++ .../apps/web/src/lib/modules/chat/queries.ts | 141 +++++ .../chat/stores/conversations.svelte.ts | 95 ++++ .../modules/chat/stores/messages.svelte.ts | 57 ++ .../modules/chat/stores/templates.svelte.ts | 84 +++ .../apps/web/src/lib/modules/chat/types.ts | 88 +++ .../src/lib/modules/contacts/collections.ts | 62 +++ .../web/src/lib/modules/contacts/index.ts | 22 + .../web/src/lib/modules/contacts/queries.ts | 122 +++++ .../contacts/stores/contacts.svelte.ts | 81 +++ .../modules/contacts/stores/filter.svelte.ts | 102 ++++ .../modules/contacts/stores/modal.svelte.ts | 45 ++ .../web/src/lib/modules/contacts/types.ts | 47 ++ .../web/src/lib/modules/memoro/collections.ts | 84 +++ .../apps/web/src/lib/modules/memoro/index.ts | 49 ++ .../web/src/lib/modules/memoro/queries.ts | 183 +++++++ .../modules/memoro/stores/memories.svelte.ts | 38 ++ .../lib/modules/memoro/stores/memos.svelte.ts | 85 +++ .../lib/modules/memoro/stores/tags.svelte.ts | 71 +++ .../apps/web/src/lib/modules/memoro/types.ts | 127 +++++ .../web/src/lib/modules/mukke/collections.ts | 41 ++ .../apps/web/src/lib/modules/mukke/index.ts | 52 ++ .../apps/web/src/lib/modules/mukke/queries.ts | 247 +++++++++ .../modules/mukke/stores/library.svelte.ts | 61 +++ .../lib/modules/mukke/stores/player.svelte.ts | 275 ++++++++++ .../modules/mukke/stores/playlists.svelte.ts | 81 +++ .../modules/mukke/stores/projects.svelte.ts | 38 ++ .../apps/web/src/lib/modules/mukke/types.ts | 122 +++++ .../src/lib/modules/picture/collections.ts | 83 +++ .../apps/web/src/lib/modules/picture/index.ts | 41 ++ .../web/src/lib/modules/picture/queries.ts | 178 +++++++ .../modules/picture/stores/boards.svelte.ts | 148 ++++++ .../modules/picture/stores/images.svelte.ts | 103 ++++ .../lib/modules/picture/stores/view.svelte.ts | 39 ++ .../apps/web/src/lib/modules/picture/types.ts | 110 ++++ .../web/src/lib/modules/todo/collections.ts | 149 ++++++ .../apps/web/src/lib/modules/todo/index.ts | 55 ++ .../apps/web/src/lib/modules/todo/queries.ts | 207 ++++++++ .../modules/todo/stores/board-views.svelte.ts | 60 +++ .../lib/modules/todo/stores/labels.svelte.ts | 32 ++ .../lib/modules/todo/stores/tasks.svelte.ts | 121 +++++ .../lib/modules/todo/stores/view.svelte.ts | 101 ++++ .../apps/web/src/lib/modules/todo/types.ts | 128 +++++ .../src/routes/(app)/calendar/+layout.svelte | 21 + .../src/routes/(app)/calendar/+page.svelte | 499 ++++++++++++++++++ .../(app)/calendar/calendars/+page.svelte | 185 +++++++ .../(app)/calendar/event/[id]/+page.svelte | 269 ++++++++++ .../web/src/routes/(app)/chat/+layout.svelte | 23 + .../web/src/routes/(app)/chat/+page.svelte | 242 +++++++++ .../src/routes/(app)/chat/[id]/+page.svelte | 216 ++++++++ .../routes/(app)/chat/archive/+page.svelte | 100 ++++ .../routes/(app)/chat/templates/+page.svelte | 310 +++++++++++ .../src/routes/(app)/contacts/+layout.svelte | 19 + .../src/routes/(app)/contacts/+page.svelte | 344 ++++++++++++ .../routes/(app)/contacts/[id]/+page.svelte | 371 +++++++++++++ .../src/routes/(app)/memoro/+layout.svelte | 26 + .../web/src/routes/(app)/memoro/+page.svelte | 262 +++++++++ .../src/routes/(app)/memoro/[id]/+page.svelte | 289 ++++++++++ .../routes/(app)/memoro/archive/+page.svelte | 108 ++++ .../src/routes/(app)/memoro/tags/+page.svelte | 224 ++++++++ .../web/src/routes/(app)/mukke/+layout.svelte | 26 + .../web/src/routes/(app)/mukke/+page.svelte | 125 +++++ .../routes/(app)/mukke/library/+page.svelte | 242 +++++++++ .../routes/(app)/mukke/playlists/+page.svelte | 177 +++++++ .../(app)/mukke/playlists/[id]/+page.svelte | 204 +++++++ .../routes/(app)/mukke/projects/+page.svelte | 177 +++++++ .../src/routes/(app)/picture/+layout.svelte | 33 ++ .../web/src/routes/(app)/picture/+page.svelte | 300 +++++++++++ .../routes/(app)/picture/archive/+page.svelte | 90 ++++ .../routes/(app)/picture/board/+page.svelte | 240 +++++++++ .../(app)/picture/board/[id]/+page.svelte | 166 ++++++ .../(app)/picture/generate/+page.svelte | 153 ++++++ .../web/src/routes/(app)/todo/+layout.svelte | 26 + .../web/src/routes/(app)/todo/+page.svelte | 471 +++++++++++++++++ 82 files changed, 10802 insertions(+) create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/collections.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/queries.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/stores/calendars.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/stores/events.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/stores/view.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/types.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/chat/collections.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/chat/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/chat/queries.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/chat/stores/messages.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/chat/stores/templates.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/chat/types.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/collections.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/queries.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/stores/filter.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/stores/modal.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/types.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/memoro/collections.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/memoro/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/memoro/queries.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/memoro/stores/memories.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/memoro/stores/tags.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/memoro/types.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/mukke/collections.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/mukke/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/mukke/queries.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/mukke/stores/library.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/mukke/stores/player.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/mukke/stores/playlists.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/mukke/stores/projects.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/mukke/types.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/picture/collections.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/picture/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/picture/queries.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/picture/stores/boards.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/picture/stores/images.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/picture/stores/view.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/picture/types.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/collections.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/queries.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/stores/board-views.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/stores/labels.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/stores/view.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/types.ts create mode 100644 apps/manacore/apps/web/src/routes/(app)/calendar/+layout.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/calendar/calendars/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/calendar/event/[id]/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/chat/+layout.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/chat/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/chat/[id]/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/chat/archive/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/chat/templates/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/contacts/+layout.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/contacts/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/contacts/[id]/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/memoro/+layout.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/memoro/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/memoro/[id]/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/memoro/archive/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/memoro/tags/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/mukke/+layout.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/mukke/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/mukke/library/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/mukke/playlists/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/mukke/playlists/[id]/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/mukke/projects/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/picture/+layout.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/picture/archive/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/picture/board/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/picture/board/[id]/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/picture/generate/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/todo/+layout.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/todo/+page.svelte diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/collections.ts b/apps/manacore/apps/web/src/lib/modules/calendar/collections.ts new file mode 100644 index 000000000..f65ad80b1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/collections.ts @@ -0,0 +1,81 @@ +/** + * Calendar module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalCalendar, LocalEvent } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const calendarTable = db.table('calendars'); +export const eventTable = db.table('events'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const PERSONAL_CALENDAR_ID = 'personal-calendar'; + +export const CALENDAR_GUEST_SEED = { + calendars: [ + { + id: PERSONAL_CALENDAR_ID, + name: 'Persönlich', + color: '#3B82F6', + isDefault: true, + isVisible: true, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + ] satisfies LocalCalendar[], + events: (() => { + const now = new Date(); + const today10 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10, 0, 0); + const today11 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 11, 0, 0); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrow14 = new Date( + tomorrow.getFullYear(), + tomorrow.getMonth(), + tomorrow.getDate(), + 14, + 0, + 0 + ); + const tomorrow15 = new Date( + tomorrow.getFullYear(), + tomorrow.getMonth(), + tomorrow.getDate(), + 15, + 30, + 0 + ); + + return [ + { + id: 'sample-event-1', + calendarId: PERSONAL_CALENDAR_ID, + title: 'Willkommen bei Kalender!', + description: + 'Dies ist ein Beispieltermin. Tippe darauf, um ihn zu bearbeiten oder zu löschen.', + startDate: today10.toISOString(), + endDate: today11.toISOString(), + allDay: false, + location: null, + recurrenceRule: null, + color: null, + reminders: null, + }, + { + id: 'sample-event-2', + calendarId: PERSONAL_CALENDAR_ID, + title: 'Mittagessen mit Freunden', + description: null, + startDate: tomorrow14.toISOString(), + endDate: tomorrow15.toISOString(), + allDay: false, + location: 'Café am See', + recurrenceRule: null, + color: null, + reminders: null, + }, + ] satisfies LocalEvent[]; + })(), +}; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/index.ts b/apps/manacore/apps/web/src/lib/modules/calendar/index.ts new file mode 100644 index 000000000..20d55c369 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/index.ts @@ -0,0 +1,26 @@ +/** + * Calendar module — barrel exports. + */ + +export { calendarsStore } from './stores/calendars.svelte'; +export { eventsStore } from './stores/events.svelte'; +export { calendarViewStore } from './stores/view.svelte'; +export { + useAllCalendars, + useAllEvents, + allCalendars$, + allEvents$, + toCalendar, + toCalendarEvent, + getVisibleCalendars, + getDefaultCalendar, + getCalendarById, + getCalendarColor, + getEventById, + getEventsForDay, + getEventsInRange, + filterEventsByVisibleCalendars, + sortEventsByTime, +} from './queries'; +export { calendarTable, eventTable, CALENDAR_GUEST_SEED } from './collections'; +export type { LocalCalendar, LocalEvent, CalendarViewType, CalendarEvent, Calendar } from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/queries.ts b/apps/manacore/apps/web/src/lib/modules/calendar/queries.ts new file mode 100644 index 000000000..1de2beb43 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/queries.ts @@ -0,0 +1,165 @@ +/** + * Reactive Queries & Pure Helpers for Calendar 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 { LocalCalendar, LocalEvent, Calendar, CalendarEvent } from './types'; +import { isSameDay, isWithinInterval, differenceInMilliseconds, format } from 'date-fns'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toCalendar(local: LocalCalendar): Calendar { + return { + id: local.id, + name: local.name, + color: local.color, + isDefault: local.isDefault, + isVisible: local.isVisible, + timezone: local.timezone, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toCalendarEvent(local: LocalEvent): CalendarEvent { + return { + id: local.id, + calendarId: local.calendarId, + title: local.title, + description: local.description ?? null, + location: local.location ?? null, + startTime: local.startDate, + endTime: local.endDate, + isAllDay: local.allDay, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + recurrenceRule: local.recurrenceRule ?? null, + parentEventId: null, + color: local.color ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Raw Observable Queries (for Svelte $ auto-subscribe) ── + +export function allCalendars$() { + return liveQuery(async () => { + const locals = await db.table('calendars').toArray(); + return locals.filter((c) => !c.deletedAt).map(toCalendar); + }); +} + +export function allEvents$() { + return liveQuery(async () => { + const locals = await db.table('events').toArray(); + return locals.filter((e) => !e.deletedAt).map(toCalendarEvent); + }); +} + +// ─── Svelte 5 Reactive Hooks (call during component init) ── + +/** All calendars, auto-updates on any change. */ +export function useAllCalendars() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('calendars').toArray(); + return locals.filter((c) => !c.deletedAt).map(toCalendar); + }, [] as Calendar[]); +} + +/** All events, auto-updates on any change. */ +export function useAllEvents() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('events').toArray(); + return locals.filter((e) => !e.deletedAt).map(toCalendarEvent); + }, [] as CalendarEvent[]); +} + +// ─── Pure Calendar Helpers ───────────────────────────────── + +/** Get visible calendars (where isVisible is true). */ +export function getVisibleCalendars(calendars: Calendar[]): Calendar[] { + return calendars.filter((c) => c.isVisible); +} + +/** Get the default calendar, falling back to the first calendar. */ +export function getDefaultCalendar(calendars: Calendar[]): Calendar | null { + return calendars.find((c) => c.isDefault) || calendars[0] || null; +} + +/** Get a calendar by ID. */ +export function getCalendarById(calendars: Calendar[], id: string): Calendar | undefined { + return calendars.find((c) => c.id === id); +} + +/** Get a calendar's color by ID, with fallback. */ +export function getCalendarColor(calendars: Calendar[], id: string): string { + const calendar = calendars.find((c) => c.id === id); + return calendar?.color || '#3b82f6'; +} + +// ─── Pure Event Helpers ──────────────────────────────────── + +/** Get an event by ID. */ +export function getEventById(events: CalendarEvent[], id: string): CalendarEvent | undefined { + return events.find((e) => e.id === id); +} + +/** Convert a date string or Date to a Date. */ +function toDate(dateStr: string | Date): Date { + return typeof dateStr === 'string' ? new Date(dateStr) : dateStr; +} + +/** + * Get events for a specific day. + */ +export function getEventsForDay(events: CalendarEvent[], date: Date): CalendarEvent[] { + return events.filter((event) => { + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); + + if (event.isAllDay) { + return ( + isWithinInterval(date, { start: eventStart, end: eventEnd }) || isSameDay(date, eventStart) + ); + } + + return isSameDay(date, eventStart); + }); +} + +/** + * Get events within a time range. + */ +export function getEventsInRange(events: CalendarEvent[], start: Date, end: Date): CalendarEvent[] { + return events.filter((event) => { + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); + return eventStart <= end && eventEnd >= start; + }); +} + +/** + * Filter events by visible calendars. + */ +export function filterEventsByVisibleCalendars( + events: CalendarEvent[], + calendars: Calendar[] +): CalendarEvent[] { + const visibleIds = new Set(calendars.filter((c) => c.isVisible).map((c) => c.id)); + return events.filter((e) => visibleIds.has(e.calendarId)); +} + +/** + * Sort events by start time. + */ +export function sortEventsByTime(events: CalendarEvent[]): CalendarEvent[] { + return [...events].sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); +} diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/stores/calendars.svelte.ts b/apps/manacore/apps/web/src/lib/modules/calendar/stores/calendars.svelte.ts new file mode 100644 index 000000000..2307fd931 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/stores/calendars.svelte.ts @@ -0,0 +1,124 @@ +/** + * Calendars Store — Mutation-Only Service + * + * All reads are handled by liveQuery hooks in queries.ts. + * This store only provides write operations (create, update, delete). + * IndexedDB writes automatically trigger UI updates via Dexie liveQuery. + */ + +import { db } from '$lib/data/database'; +import type { LocalCalendar } from '../types'; +import type { Calendar } from '../types'; +import { toCalendar } from '../queries'; + +let error = $state(null); + +export const calendarsStore = { + get error() { + return error; + }, + + /** + * Create a new calendar -- writes to IndexedDB instantly. + */ + async createCalendar(input: { + name: string; + color?: string; + isDefault?: boolean; + isVisible?: boolean; + timezone?: string; + }) { + error = null; + try { + const newLocal: LocalCalendar = { + id: crypto.randomUUID(), + name: input.name, + color: input.color ?? '#3B82F6', + isDefault: input.isDefault ?? false, + isVisible: input.isVisible ?? true, + timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.table('calendars').add(newLocal); + return { success: true, data: toCalendar(newLocal) }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create calendar'; + return { success: false, error }; + } + }, + + /** + * Update a calendar -- writes to IndexedDB instantly. + */ + async updateCalendar(id: string, input: Partial>) { + error = null; + try { + await db.table('calendars').update(id, { + ...input, + updatedAt: new Date().toISOString(), + }); + const updated = await db.table('calendars').get(id); + if (updated) { + return { success: true, data: toCalendar(updated) }; + } + return { success: false, error: 'Calendar not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update calendar'; + return { success: false, error }; + } + }, + + /** + * Delete a calendar -- soft-deletes from IndexedDB instantly. + */ + async deleteCalendar(id: string) { + error = null; + try { + await db.table('calendars').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete calendar'; + return { success: false, error }; + } + }, + + /** + * Toggle calendar visibility. + */ + async toggleVisibility(id: string, calendars: Calendar[]) { + const calendar = calendars.find((c) => c.id === id); + if (!calendar) return { success: false, error: 'Calendar not found' }; + return this.updateCalendar(id, { isVisible: !calendar.isVisible }); + }, + + /** + * Set a calendar as the default. + */ + async setAsDefault(id: string, calendars: Calendar[]) { + error = null; + try { + for (const cal of calendars) { + if (cal.isDefault && cal.id !== id) { + await db.table('calendars').update(cal.id, { + isDefault: false, + updatedAt: new Date().toISOString(), + }); + } + } + await db.table('calendars').update(id, { + isDefault: true, + updatedAt: new Date().toISOString(), + }); + const updated = await db.table('calendars').get(id); + return { success: true, data: updated ? toCalendar(updated) : null }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to set default'; + return { success: false, error }; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/stores/events.svelte.ts b/apps/manacore/apps/web/src/lib/modules/calendar/stores/events.svelte.ts new file mode 100644 index 000000000..27fc14f89 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/stores/events.svelte.ts @@ -0,0 +1,167 @@ +/** + * Events Store — Mutation-Only Service + * + * All reads are handled by liveQuery hooks in queries.ts. + * This store only provides write operations and draft event state. + * IndexedDB writes automatically trigger UI updates via Dexie liveQuery. + */ + +import { db } from '$lib/data/database'; +import type { LocalEvent, CalendarEvent } from '../types'; +import { toCalendarEvent } from '../queries'; + +let error = $state(null); +let draftEvent = $state(null); + +export const eventsStore = { + get error() { + return error; + }, + get draftEvent() { + return draftEvent; + }, + + /** + * Create a new event -- writes to IndexedDB instantly. + */ + async createEvent(input: { + calendarId: string; + title: string; + description?: string | null; + startTime: string; + endTime: string; + isAllDay?: boolean; + location?: string | null; + recurrenceRule?: string | null; + color?: string | null; + }) { + error = null; + try { + const newLocal: LocalEvent = { + id: crypto.randomUUID(), + calendarId: input.calendarId, + title: input.title, + description: input.description ?? null, + startDate: input.startTime, + endDate: input.endTime, + allDay: input.isAllDay ?? false, + location: input.location ?? null, + recurrenceRule: input.recurrenceRule ?? null, + color: input.color ?? null, + reminders: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.table('events').add(newLocal); + return { success: true, data: toCalendarEvent(newLocal) }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create event'; + return { success: false, error }; + } + }, + + /** + * Update an event -- writes to IndexedDB instantly. + */ + async updateEvent( + id: string, + input: { + title?: string; + description?: string | null; + startTime?: string; + endTime?: string; + isAllDay?: boolean; + location?: string | null; + recurrenceRule?: string | null; + color?: string | null; + calendarId?: string; + } + ) { + error = null; + try { + const localData: Partial = { + updatedAt: new Date().toISOString(), + }; + if (input.title !== undefined) localData.title = input.title; + if (input.description !== undefined) localData.description = input.description; + if (input.startTime !== undefined) localData.startDate = input.startTime; + if (input.endTime !== undefined) localData.endDate = input.endTime; + if (input.isAllDay !== undefined) localData.allDay = input.isAllDay; + if (input.location !== undefined) localData.location = input.location; + if (input.recurrenceRule !== undefined) localData.recurrenceRule = input.recurrenceRule; + if (input.color !== undefined) localData.color = input.color; + if (input.calendarId !== undefined) localData.calendarId = input.calendarId; + + await db.table('events').update(id, localData); + const updated = await db.table('events').get(id); + if (updated) { + return { success: true, data: toCalendarEvent(updated) }; + } + return { success: false, error: 'Event not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update event'; + return { success: false, error }; + } + }, + + /** + * Delete an event -- soft-deletes from IndexedDB instantly. + */ + async deleteEvent(id: string) { + error = null; + try { + await db.table('events').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete event'; + return { success: false, error }; + } + }, + + // ========== Draft Event Methods ========== + + createDraftEvent(data: Partial) { + draftEvent = { + id: '__draft__', + calendarId: data.calendarId || '', + title: data.title || '', + description: data.description || null, + location: data.location || null, + startTime: data.startTime || new Date().toISOString(), + endTime: data.endTime || new Date().toISOString(), + isAllDay: data.isAllDay || false, + timezone: null, + recurrenceRule: null, + parentEventId: null, + color: data.color || null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return draftEvent; + }, + + updateDraftEvent(data: Partial) { + if (draftEvent) { + draftEvent = { ...draftEvent, ...data }; + } + }, + + clearDraftEvent() { + draftEvent = null; + }, + + isDraftEvent(eventId: string) { + return eventId === '__draft__'; + }, + + getParentEventId(eventId: string): string { + if (eventId.includes('__recurrence__')) { + return eventId.split('__recurrence__')[0]; + } + return eventId; + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/stores/view.svelte.ts b/apps/manacore/apps/web/src/lib/modules/calendar/stores/view.svelte.ts new file mode 100644 index 000000000..d68aeaf9d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/stores/view.svelte.ts @@ -0,0 +1,118 @@ +/** + * Calendar View Store — Manages view state (current date, view type, navigation). + */ + +import { browser } from '$app/environment'; +import type { CalendarViewType } from '../types'; +import { + startOfDay, + startOfWeek, + startOfMonth, + endOfDay, + endOfWeek, + endOfMonth, + addDays, + addWeeks, + addMonths, + subWeeks, + subMonths, +} from 'date-fns'; + +const SUPPORTED_VIEWS: CalendarViewType[] = ['week', 'month', 'agenda']; + +let currentDate = $state(new Date()); +let viewType = $state('week'); + +const viewRange = $derived.by(() => { + const weekStartsOn = 1 as 0 | 1; // Monday + + switch (viewType) { + case 'week': + return { + start: startOfWeek(currentDate, { weekStartsOn }), + end: endOfWeek(currentDate, { weekStartsOn }), + }; + case 'month': + return { + start: startOfMonth(currentDate), + end: endOfMonth(currentDate), + }; + case 'agenda': + return { + start: startOfDay(currentDate), + end: endOfDay(addDays(currentDate, 30)), + }; + default: + return { + start: startOfWeek(currentDate, { weekStartsOn }), + end: endOfWeek(currentDate, { weekStartsOn }), + }; + } +}); + +export const calendarViewStore = { + get currentDate() { + return currentDate; + }, + get viewType() { + return viewType; + }, + get viewRange() { + return viewRange; + }, + + initialize() { + if (!browser) return; + + const savedView = localStorage.getItem('manacore-calendar-view-type'); + if (savedView && SUPPORTED_VIEWS.includes(savedView as CalendarViewType)) { + viewType = savedView as CalendarViewType; + } + }, + + setDate(date: Date) { + currentDate = date; + }, + + setViewType(type: CalendarViewType) { + if (!SUPPORTED_VIEWS.includes(type)) { + type = 'week'; + } + viewType = type; + if (browser) { + localStorage.setItem('manacore-calendar-view-type', type); + } + }, + + goToToday() { + currentDate = new Date(); + }, + + goToPrevious() { + switch (viewType) { + case 'week': + currentDate = subWeeks(currentDate, 1); + break; + case 'month': + currentDate = subMonths(currentDate, 1); + break; + case 'agenda': + currentDate = subWeeks(currentDate, 1); + break; + } + }, + + goToNext() { + switch (viewType) { + case 'week': + currentDate = addWeeks(currentDate, 1); + break; + case 'month': + currentDate = addMonths(currentDate, 1); + break; + case 'agenda': + currentDate = addWeeks(currentDate, 1); + break; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/types.ts b/apps/manacore/apps/web/src/lib/modules/calendar/types.ts new file mode 100644 index 000000000..94e722c25 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/types.ts @@ -0,0 +1,56 @@ +/** + * Calendar module types for the unified ManaCore app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalCalendar extends BaseRecord { + name: string; + color: string; + isDefault: boolean; + isVisible: boolean; + timezone: string; +} + +export interface LocalEvent extends BaseRecord { + calendarId: string; + title: string; + description?: string | null; + startDate: string; + endDate: string; + allDay: boolean; + location?: string | null; + recurrenceRule?: string | null; + color?: string | null; + reminders?: unknown | null; +} + +export type CalendarViewType = 'week' | 'month' | 'agenda'; + +export interface CalendarEvent { + id: string; + calendarId: string; + title: string; + description: string | null; + location: string | null; + startTime: string; + endTime: string; + isAllDay: boolean; + timezone: string | null; + recurrenceRule: string | null; + parentEventId: string | null; + color: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Calendar { + id: string; + name: string; + color: string; + isDefault: boolean; + isVisible: boolean; + timezone: string; + createdAt: string; + updatedAt: string; +} diff --git a/apps/manacore/apps/web/src/lib/modules/chat/collections.ts b/apps/manacore/apps/web/src/lib/modules/chat/collections.ts new file mode 100644 index 000000000..88c3b7dbb --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/chat/collections.ts @@ -0,0 +1,41 @@ +/** + * Chat module — collection accessors and guest seed data. + * + * Table names: conversations, messages, chatTemplates + */ + +import { db } from '$lib/data/database'; +import type { LocalConversation, LocalMessage, LocalTemplate } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const conversationTable = db.table('conversations'); +export const messageTable = db.table('messages'); +export const chatTemplateTable = db.table('chatTemplates'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const DEMO_CONVERSATION_ID = 'demo-welcome-chat'; + +export const CHAT_GUEST_SEED = { + conversations: [ + { + id: DEMO_CONVERSATION_ID, + title: 'Willkommen bei ManaChat', + conversationMode: 'free' as const, + documentMode: false, + isArchived: false, + isPinned: true, + }, + ], + messages: [ + { + id: 'msg-welcome-1', + conversationId: DEMO_CONVERSATION_ID, + sender: 'assistant' as const, + messageText: + 'Hallo! Ich bin dein KI-Assistent. Stelle mir eine Frage oder starte eine Unterhaltung.', + }, + ], + chatTemplates: [] as Record[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/chat/index.ts b/apps/manacore/apps/web/src/lib/modules/chat/index.ts new file mode 100644 index 000000000..989eb484c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/chat/index.ts @@ -0,0 +1,31 @@ +/** + * Chat module — barrel exports. + */ + +export { conversationsStore } from './stores/conversations.svelte'; +export { messagesStore } from './stores/messages.svelte'; +export { templatesStore } from './stores/templates.svelte'; +export { + useAllConversations, + useArchivedConversations, + useAllTemplates, + useConversationMessages, + toConversation, + toTemplate, + toMessage, + sortConversations, + filterBySpace, + filterBySearch, + splitPinned, +} from './queries'; +export { conversationTable, messageTable, chatTemplateTable, CHAT_GUEST_SEED } from './collections'; +export type { + LocalConversation, + LocalMessage, + LocalTemplate, + Conversation, + Message, + Template, + AIModel, + ChatMessage, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/chat/queries.ts b/apps/manacore/apps/web/src/lib/modules/chat/queries.ts new file mode 100644 index 000000000..a3d82662b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/chat/queries.ts @@ -0,0 +1,141 @@ +/** + * Reactive queries & pure helpers for Chat — uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { + LocalConversation, + LocalMessage, + LocalTemplate, + Conversation, + Message, + Template, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toConversation(local: LocalConversation): Conversation { + return { + id: local.id, + userId: local.userId ?? 'guest', + modelId: local.modelId ?? '', + templateId: local.templateId ?? undefined, + spaceId: local.spaceId ?? undefined, + conversationMode: local.conversationMode, + documentMode: local.documentMode, + title: local.title ?? undefined, + isArchived: local.isArchived, + isPinned: local.isPinned, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toTemplate(local: LocalTemplate): Template { + return { + id: local.id, + userId: local.userId ?? 'guest', + name: local.name, + description: local.description || null, + systemPrompt: local.systemPrompt, + initialQuestion: local.initialQuestion ?? null, + modelId: local.modelId ?? null, + color: local.color, + isDefault: local.isDefault, + documentMode: local.documentMode, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toMessage(local: LocalMessage): Message { + return { + id: local.id, + conversationId: local.conversationId, + sender: local.sender, + messageText: local.messageText, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? undefined, + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +/** All non-archived conversations, sorted by pinned first then updatedAt desc. */ +export function useAllConversations() { + return liveQuery(async () => { + const locals = await db.table('conversations').toArray(); + return sortConversations( + locals.filter((c) => !c.deletedAt && !c.isArchived).map(toConversation) + ); + }); +} + +/** All archived conversations, sorted by updatedAt desc. */ +export function useArchivedConversations() { + return liveQuery(async () => { + const locals = await db.table('conversations').toArray(); + return locals + .filter((c) => !c.deletedAt && c.isArchived) + .map(toConversation) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + }); +} + +/** All templates, sorted by name. */ +export function useAllTemplates() { + return liveQuery(async () => { + const locals = await db.table('chatTemplates').toArray(); + return locals + .filter((t) => !t.deletedAt) + .map(toTemplate) + .sort((a, b) => a.name.localeCompare(b.name)); + }); +} + +/** Messages for a specific conversation, sorted by createdAt asc. */ +export function useConversationMessages(conversationId: string) { + return liveQuery(async () => { + const locals = await db + .table('messages') + .where('conversationId') + .equals(conversationId) + .toArray(); + return locals + .filter((m) => !m.deletedAt) + .map(toMessage) + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + }); +} + +// ─── Pure Sort / Filter Functions (for $derived) ─────────── + +/** Sort conversations: pinned first, then by updatedAt descending. */ +export function sortConversations(list: Conversation[]): Conversation[] { + return [...list].sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); +} + +/** Filter conversations by space. */ +export function filterBySpace(conversations: Conversation[], spaceId: string): Conversation[] { + return conversations.filter((c) => c.spaceId === spaceId); +} + +/** Filter conversations by search query on title. */ +export function filterBySearch(conversations: Conversation[], query: string): Conversation[] { + if (!query.trim()) return conversations; + const lower = query.toLowerCase(); + return conversations.filter((c) => c.title?.toLowerCase().includes(lower)); +} + +/** Split conversations into pinned and unpinned. */ +export function splitPinned(conversations: Conversation[]) { + return { + pinned: conversations.filter((c) => c.isPinned), + unpinned: conversations.filter((c) => !c.isPinned), + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts b/apps/manacore/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts new file mode 100644 index 000000000..f44be5b60 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts @@ -0,0 +1,95 @@ +/** + * Conversations Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * This store only handles writes to IndexedDB via the unified database. + */ + +import { conversationTable, messageTable } from '../collections'; +import { toConversation } from '../queries'; +import type { LocalConversation } from '../types'; + +export const conversationsStore = { + /** Create a new conversation. */ + async create(data: { + modelId?: string; + templateId?: string; + spaceId?: string; + mode?: 'free' | 'guided' | 'template'; + documentMode?: boolean; + title?: string; + }) { + const newLocal: LocalConversation = { + id: crypto.randomUUID(), + title: data.title ?? null, + modelId: data.modelId ?? null, + templateId: data.templateId ?? null, + spaceId: data.spaceId ?? null, + conversationMode: data.mode ?? 'free', + documentMode: data.documentMode ?? false, + isArchived: false, + isPinned: false, + }; + await conversationTable.add(newLocal); + return toConversation(newLocal); + }, + + /** Update a conversation's fields. */ + async update(id: string, updates: Partial) { + await conversationTable.update(id, { + ...updates, + updatedAt: new Date().toISOString(), + }); + }, + + /** Update conversation title. */ + async updateTitle(id: string, title: string) { + await conversationTable.update(id, { + title, + updatedAt: new Date().toISOString(), + }); + }, + + /** Archive a conversation. */ + async archive(id: string) { + await conversationTable.update(id, { + isArchived: true, + updatedAt: new Date().toISOString(), + }); + }, + + /** Unarchive a conversation. */ + async unarchive(id: string) { + await conversationTable.update(id, { + isArchived: false, + updatedAt: new Date().toISOString(), + }); + }, + + /** Pin a conversation. */ + async pin(id: string) { + await conversationTable.update(id, { + isPinned: true, + updatedAt: new Date().toISOString(), + }); + }, + + /** Unpin a conversation. */ + async unpin(id: string) { + await conversationTable.update(id, { + isPinned: false, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a conversation and its messages. */ + async delete(id: string) { + const now = new Date().toISOString(); + await conversationTable.update(id, { deletedAt: now, updatedAt: now }); + // Soft-delete all messages for this conversation + const msgs = await messageTable.where('conversationId').equals(id).toArray(); + for (const msg of msgs) { + await messageTable.update(msg.id, { deletedAt: now, updatedAt: now }); + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/chat/stores/messages.svelte.ts b/apps/manacore/apps/web/src/lib/modules/chat/stores/messages.svelte.ts new file mode 100644 index 000000000..8a2f5dd97 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/chat/stores/messages.svelte.ts @@ -0,0 +1,57 @@ +/** + * Messages Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * This store handles adding/deleting messages in IndexedDB. + */ + +import { messageTable, conversationTable } from '../collections'; +import { toMessage } from '../queries'; +import type { LocalMessage } from '../types'; + +export const messagesStore = { + /** Add a user message to a conversation. */ + async addUserMessage(conversationId: string, text: string) { + const newLocal: LocalMessage = { + id: crypto.randomUUID(), + conversationId, + sender: 'user', + messageText: text, + }; + await messageTable.add(newLocal); + // Touch the conversation's updatedAt + await conversationTable.update(conversationId, { + updatedAt: new Date().toISOString(), + }); + return toMessage(newLocal); + }, + + /** Add an assistant message to a conversation. */ + async addAssistantMessage(conversationId: string, text: string) { + const newLocal: LocalMessage = { + id: crypto.randomUUID(), + conversationId, + sender: 'assistant', + messageText: text, + }; + await messageTable.add(newLocal); + await conversationTable.update(conversationId, { + updatedAt: new Date().toISOString(), + }); + return toMessage(newLocal); + }, + + /** Update a message's text (e.g., during streaming). */ + async updateText(id: string, text: string) { + await messageTable.update(id, { + messageText: text, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a message. */ + async delete(id: string) { + const now = new Date().toISOString(); + await messageTable.update(id, { deletedAt: now, updatedAt: now }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/chat/stores/templates.svelte.ts b/apps/manacore/apps/web/src/lib/modules/chat/stores/templates.svelte.ts new file mode 100644 index 000000000..113619ad3 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/chat/stores/templates.svelte.ts @@ -0,0 +1,84 @@ +/** + * Templates Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * This store handles template CRUD in IndexedDB. + */ + +import { chatTemplateTable } from '../collections'; +import { toTemplate } from '../queries'; +import type { LocalTemplate } from '../types'; + +export const templatesStore = { + /** Create a new template. */ + async create(data: { + name: string; + description?: string; + systemPrompt: string; + initialQuestion?: string; + modelId?: string; + color: string; + isDefault?: boolean; + documentMode?: boolean; + }) { + const newLocal: LocalTemplate = { + id: crypto.randomUUID(), + name: data.name, + description: data.description ?? '', + systemPrompt: data.systemPrompt, + initialQuestion: data.initialQuestion ?? null, + modelId: data.modelId ?? null, + color: data.color, + isDefault: data.isDefault ?? false, + documentMode: data.documentMode ?? false, + }; + await chatTemplateTable.add(newLocal); + return toTemplate(newLocal); + }, + + /** Update a template. */ + async update( + id: string, + data: Partial< + Pick< + LocalTemplate, + | 'name' + | 'description' + | 'systemPrompt' + | 'initialQuestion' + | 'modelId' + | 'color' + | 'isDefault' + | 'documentMode' + > + > + ) { + await chatTemplateTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a template. */ + async delete(id: string) { + const now = new Date().toISOString(); + await chatTemplateTable.update(id, { deletedAt: now, updatedAt: now }); + }, + + /** Set a template as default (unset all others). */ + async setDefault(templateId: string) { + const all = await chatTemplateTable.toArray(); + for (const t of all) { + if (t.isDefault && t.id !== templateId) { + await chatTemplateTable.update(t.id, { + isDefault: false, + updatedAt: new Date().toISOString(), + }); + } + } + await chatTemplateTable.update(templateId, { + isDefault: true, + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/chat/types.ts b/apps/manacore/apps/web/src/lib/modules/chat/types.ts new file mode 100644 index 000000000..f201eee09 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/chat/types.ts @@ -0,0 +1,88 @@ +/** + * Chat module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalConversation extends BaseRecord { + title?: string | null; + modelId?: string | null; + templateId?: string | null; + spaceId?: string | null; + conversationMode: 'free' | 'guided' | 'template'; + documentMode: boolean; + isArchived: boolean; + isPinned: boolean; +} + +export interface LocalMessage extends BaseRecord { + conversationId: string; + sender: 'user' | 'assistant' | 'system'; + messageText: string; +} + +export interface LocalTemplate extends BaseRecord { + name: string; + description: string; + systemPrompt: string; + initialQuestion?: string | null; + modelId?: string | null; + color: string; + isDefault: boolean; + documentMode: boolean; +} + +// ─── View Types (used in UI, decoupled from local-store BaseRecord) ─── + +export interface Conversation { + id: string; + userId: string; + modelId: string; + templateId?: string; + spaceId?: string; + conversationMode: 'free' | 'guided' | 'template'; + documentMode: boolean; + title?: string; + isArchived: boolean; + isPinned: boolean; + createdAt: string; + updatedAt: string; +} + +export interface Message { + id: string; + conversationId: string; + sender: 'user' | 'assistant' | 'system'; + messageText: string; + createdAt: string; + updatedAt?: string; +} + +export interface Template { + id: string; + userId: string; + name: string; + description: string | null; + systemPrompt: string; + initialQuestion?: string | null; + modelId?: string | null; + color: string; + isDefault: boolean; + documentMode: boolean; + createdAt: string; + updatedAt: string; +} + +export interface AIModel { + id: string; + name: string; + provider: string; + description?: string; + isDefault?: boolean; + isLocal?: boolean; +} + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/collections.ts b/apps/manacore/apps/web/src/lib/modules/contacts/collections.ts new file mode 100644 index 000000000..20ee451ed --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/collections.ts @@ -0,0 +1,62 @@ +/** + * Contacts module — collection accessors and guest seed data. + * + * Uses the 'contacts' table in the unified DB. + */ + +import { db } from '$lib/data/database'; +import type { LocalContact } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const contactTable = db.table('contacts'); + +// ─── Guest Seed ──────────────────────────────────────────── + +export const CONTACTS_GUEST_SEED = { + contacts: [ + { + id: 'guest-contact-1', + firstName: 'Anna', + lastName: 'Mueller', + email: 'anna.mueller@example.com', + phone: '+49 30 12345678', + company: 'Tech Solutions GmbH', + jobTitle: 'Product Manager', + address: 'Berlin, Deutschland', + notes: 'Ansprechpartnerin fuer das neue Projekt', + birthday: '1990-06-15', + tags: ['Arbeit'], + isFavorite: true, + isArchived: false, + }, + { + id: 'guest-contact-2', + firstName: 'Max', + lastName: 'Schmidt', + email: 'max.schmidt@example.com', + phone: '+49 171 9876543', + company: 'Design Studio', + jobTitle: 'UX Designer', + address: 'Muenchen, Deutschland', + tags: ['Arbeit', 'Freunde'], + isFavorite: false, + isArchived: false, + }, + { + id: 'guest-contact-3', + firstName: 'Lisa', + lastName: 'Weber', + email: 'lisa.w@example.com', + phone: '+49 40 87654321', + company: '', + jobTitle: '', + address: 'Hamburg, Deutschland', + notes: 'Geburtstag nicht vergessen!', + birthday: '1992-03-22', + tags: ['Familie'], + isFavorite: true, + isArchived: false, + }, + ] satisfies LocalContact[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/index.ts b/apps/manacore/apps/web/src/lib/modules/contacts/index.ts new file mode 100644 index 000000000..60a2e3ccd --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/index.ts @@ -0,0 +1,22 @@ +/** + * Contacts module — barrel exports. + */ + +export { contactsStore } from './stores/contacts.svelte'; +export { contactsFilterStore } from './stores/filter.svelte'; +export { contactModalStore } from './stores/modal.svelte'; +export { + useAllContacts, + toContact, + getDisplayName, + getInitials, + searchContacts, + filterFavorites, + filterArchived, + filterActive, + sortContacts, + applyContactFilter, + groupByLetter, +} from './queries'; +export { contactTable, CONTACTS_GUEST_SEED } from './collections'; +export type { LocalContact, Contact, SortField, ContactFilter, ContactView } from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/queries.ts b/apps/manacore/apps/web/src/lib/modules/contacts/queries.ts new file mode 100644 index 000000000..cd7ca5d85 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/queries.ts @@ -0,0 +1,122 @@ +/** + * Reactive queries & pure helpers for Contacts — uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalContact, Contact, SortField, ContactFilter } from './types'; + +// ─── Type Converter ─────────────────────────────────────── + +export function toContact(local: LocalContact): Contact { + const firstName = local.firstName || null; + const lastName = local.lastName || null; + const displayName = [firstName, lastName].filter(Boolean).join(' ') || null; + + return { + id: local.id, + userId: 'local', + firstName, + lastName, + displayName, + email: local.email || null, + phone: local.phone || null, + company: local.company || null, + jobTitle: local.jobTitle || null, + notes: local.notes || null, + photoUrl: local.photoUrl || null, + birthday: local.birthday || null, + tags: (local.tags || []).map((name, i) => ({ id: `tag-${i}`, name, color: null })), + isFavorite: local.isFavorite ?? false, + isArchived: local.isArchived ?? false, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllContacts() { + return liveQuery(async () => { + const locals = await db.table('contacts').toArray(); + return locals.filter((c) => !c.deletedAt).map(toContact); + }); +} + +// ─── Display Helpers ────────────────────────────────────── + +export function getDisplayName(contact: Contact): string { + if (contact.displayName) return contact.displayName; + const parts = [contact.firstName, contact.lastName].filter(Boolean); + return parts.length > 0 ? parts.join(' ') : 'Unbenannt'; +} + +export function getInitials(contact: Contact): string { + const first = contact.firstName?.[0] ?? ''; + const last = contact.lastName?.[0] ?? ''; + const result = (first + last).toUpperCase(); + return result || '?'; +} + +// ─── Pure Filter Functions ──────────────────────────────── + +export function searchContacts(contacts: Contact[], query: string): Contact[] { + if (!query.trim()) return contacts; + const search = query.toLowerCase().trim(); + return contacts.filter( + (c) => + c.firstName?.toLowerCase().includes(search) || + c.lastName?.toLowerCase().includes(search) || + c.displayName?.toLowerCase().includes(search) || + c.email?.toLowerCase().includes(search) || + c.company?.toLowerCase().includes(search) || + c.phone?.toLowerCase().includes(search) + ); +} + +export function filterFavorites(contacts: Contact[]): Contact[] { + return contacts.filter((c) => c.isFavorite); +} + +export function filterArchived(contacts: Contact[]): Contact[] { + return contacts.filter((c) => c.isArchived); +} + +export function filterActive(contacts: Contact[]): Contact[] { + return contacts.filter((c) => !c.isArchived); +} + +export function sortContacts(contacts: Contact[], field: SortField): Contact[] { + return [...contacts].sort((a, b) => { + const aVal = (a[field] ?? '').toLowerCase(); + const bVal = (b[field] ?? '').toLowerCase(); + return aVal.localeCompare(bVal, 'de'); + }); +} + +export function applyContactFilter(contacts: Contact[], filter: ContactFilter): Contact[] { + switch (filter) { + case 'favorites': + return contacts.filter((c) => c.isFavorite); + case 'hasPhone': + return contacts.filter((c) => !!c.phone); + case 'hasEmail': + return contacts.filter((c) => !!c.email); + case 'incomplete': + return contacts.filter((c) => !c.email && !c.phone); + default: + return contacts; + } +} + +/** Group contacts by first letter of the given sort field. */ +export function groupByLetter(contacts: Contact[], field: SortField): Record { + const groups: Record = {}; + for (const contact of contacts) { + const value = contact[field] ?? ''; + const letter = (value[0] ?? '#').toUpperCase(); + if (!groups[letter]) groups[letter] = []; + groups[letter].push(contact); + } + return groups; +} diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts b/apps/manacore/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts new file mode 100644 index 000000000..779c0220a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts @@ -0,0 +1,81 @@ +/** + * Contacts Store — Mutation-Only + * + * All reads are handled by liveQuery hooks in queries.ts. + * This store only exposes mutations that write to IndexedDB. + */ + +import { contactTable } from '../collections'; +import { toContact } from '../queries'; +import type { LocalContact, Contact } from '../types'; + +export const contactsStore = { + async createContact(data: Partial) { + const newLocal: LocalContact = { + id: crypto.randomUUID(), + firstName: data.firstName ?? undefined, + lastName: data.lastName ?? undefined, + email: data.email ?? undefined, + phone: data.phone ?? undefined, + company: data.company ?? undefined, + jobTitle: data.jobTitle ?? undefined, + notes: data.notes ?? undefined, + photoUrl: data.photoUrl ?? undefined, + birthday: data.birthday ?? undefined, + tags: data.tags?.map((t) => t.name) ?? [], + isFavorite: false, + isArchived: false, + }; + + await contactTable.add(newLocal); + return toContact(newLocal); + }, + + async updateContact(id: string, data: Partial) { + const updateData: Partial = {}; + if (data.firstName !== undefined) updateData.firstName = data.firstName ?? undefined; + if (data.lastName !== undefined) updateData.lastName = data.lastName ?? undefined; + if (data.email !== undefined) updateData.email = data.email ?? undefined; + if (data.phone !== undefined) updateData.phone = data.phone ?? undefined; + if (data.company !== undefined) updateData.company = data.company ?? undefined; + if (data.jobTitle !== undefined) updateData.jobTitle = data.jobTitle ?? undefined; + if (data.notes !== undefined) updateData.notes = data.notes ?? undefined; + if (data.photoUrl !== undefined) updateData.photoUrl = data.photoUrl ?? undefined; + if (data.birthday !== undefined) updateData.birthday = data.birthday ?? undefined; + if (data.tags !== undefined) updateData.tags = data.tags?.map((t) => t.name) ?? []; + if (data.isFavorite !== undefined) updateData.isFavorite = data.isFavorite; + if (data.isArchived !== undefined) updateData.isArchived = data.isArchived; + + await contactTable.update(id, { + ...updateData, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteContact(id: string) { + await contactTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async toggleFavorite(id: string) { + const local = await contactTable.get(id); + if (!local) return; + + await contactTable.update(id, { + isFavorite: !local.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleArchive(id: string) { + const local = await contactTable.get(id); + if (!local) return; + + await contactTable.update(id, { + isArchived: !local.isArchived, + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/stores/filter.svelte.ts b/apps/manacore/apps/web/src/lib/modules/contacts/stores/filter.svelte.ts new file mode 100644 index 000000000..564b03caf --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/stores/filter.svelte.ts @@ -0,0 +1,102 @@ +/** + * Filter Store — Manages filter state for the Contacts module toolbar. + * Uses Svelte 5 runes for reactivity. + */ + +import { browser } from '$app/environment'; +import type { SortField, ContactFilter, ContactView } from '../types'; + +export interface ContactsFilterState { + sortField: SortField; + contactFilter: ContactFilter; + selectedTagId: string | null; + selectedCompany: string | null; + searchQuery: string; + viewMode: ContactView; +} + +const DEFAULT_STATE: ContactsFilterState = { + sortField: 'lastName', + contactFilter: 'all', + selectedTagId: null, + selectedCompany: null, + searchQuery: '', + viewMode: 'alphabet', +}; + +const STORAGE_KEY = 'manacore-contacts-filter-state'; + +function loadState(): ContactsFilterState { + if (!browser) return DEFAULT_STATE; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) return { ...DEFAULT_STATE, ...JSON.parse(stored) }; + } catch { + // ignore + } + return DEFAULT_STATE; +} + +function saveState(state: ContactsFilterState) { + if (!browser) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // ignore + } +} + +let state = $state(DEFAULT_STATE); + +function update( + key: K, + value: ContactsFilterState[K], + persist = true +) { + state = { ...state, [key]: value }; + if (persist) saveState(state); +} + +export const contactsFilterStore = { + get sortField() { + return state.sortField; + }, + get contactFilter() { + return state.contactFilter; + }, + get selectedTagId() { + return state.selectedTagId; + }, + get selectedCompany() { + return state.selectedCompany; + }, + get searchQuery() { + return state.searchQuery; + }, + get viewMode() { + return state.viewMode; + }, + + setSortField: (value: SortField) => update('sortField', value), + setContactFilter: (value: ContactFilter) => update('contactFilter', value), + setSelectedTagId: (value: string | null) => update('selectedTagId', value), + setSelectedCompany: (value: string | null) => update('selectedCompany', value), + setSearchQuery: (value: string) => update('searchQuery', value, false), + setViewMode: (value: ContactView) => update('viewMode', value), + + resetFilters() { + state = { + ...state, + contactFilter: 'all', + selectedTagId: null, + selectedCompany: null, + searchQuery: '', + }; + saveState(state); + }, + + initialize() { + if (!browser) return; + state = loadState(); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/stores/modal.svelte.ts b/apps/manacore/apps/web/src/lib/modules/contacts/stores/modal.svelte.ts new file mode 100644 index 000000000..70746d870 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/stores/modal.svelte.ts @@ -0,0 +1,45 @@ +/** + * Store for controlling the New Contact Modal. + */ + +interface NewContactData { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + company?: string; +} + +let isOpen = $state(false); +let prefillData = $state(null); +let editContactId = $state(null); + +export const contactModalStore = { + get isOpen() { + return isOpen; + }, + get prefillData() { + return prefillData; + }, + get editContactId() { + return editContactId; + }, + + open(data?: NewContactData) { + prefillData = data || null; + editContactId = null; + isOpen = true; + }, + + openEdit(contactId: string) { + editContactId = contactId; + prefillData = null; + isOpen = true; + }, + + close() { + isOpen = false; + prefillData = null; + editContactId = null; + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/types.ts b/apps/manacore/apps/web/src/lib/modules/contacts/types.ts new file mode 100644 index 000000000..d9d4ceba1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/types.ts @@ -0,0 +1,47 @@ +/** + * Contacts module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalContact extends BaseRecord { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + company?: string; + jobTitle?: string; + address?: string; + notes?: string; + photoUrl?: string; + birthday?: string; + tags?: string[]; + isFavorite?: boolean; + isArchived?: boolean; +} + +// ─── Shared Contact Type ────────────────────────────────── + +export interface Contact { + id: string; + userId: string; + firstName?: string | null; + lastName?: string | null; + displayName?: string | null; + email?: string | null; + phone?: string | null; + company?: string | null; + jobTitle?: string | null; + notes?: string | null; + photoUrl?: string | null; + birthday?: string | null; + tags: Array<{ id: string; name: string; color: string | null }>; + isFavorite: boolean; + isArchived: boolean; + createdAt: string; + updatedAt: string; +} + +export type SortField = 'firstName' | 'lastName'; +export type ContactFilter = 'all' | 'favorites' | 'hasPhone' | 'hasEmail' | 'incomplete'; +export type ContactView = 'grid' | 'alphabet'; diff --git a/apps/manacore/apps/web/src/lib/modules/memoro/collections.ts b/apps/manacore/apps/web/src/lib/modules/memoro/collections.ts new file mode 100644 index 000000000..3cc50241c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/memoro/collections.ts @@ -0,0 +1,84 @@ +/** + * Memoro module — collection accessors and guest seed data. + * + * Table names: memos, memories, memoroTags, memoTags, memoroSpaces, spaceMembers, memoSpaces + */ + +import { db } from '$lib/data/database'; +import type { + LocalMemo, + LocalMemory, + LocalTag, + LocalMemoTag, + LocalSpace, + LocalSpaceMember, + LocalMemoSpace, +} from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const memoTable = db.table('memos'); +export const memoryTable = db.table('memories'); +export const memoroTagTable = db.table('memoroTags'); +export const memoTagTable = db.table('memoTags'); +export const memoroSpaceTable = db.table('memoroSpaces'); +export const spaceMemberTable = db.table('spaceMembers'); +export const memoSpaceTable = db.table('memoSpaces'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const DEMO_MEMO_ID = 'demo-welcome-memo'; + +export const MEMORO_GUEST_SEED = { + memos: [ + { + id: DEMO_MEMO_ID, + title: 'Willkommen bei Memoro', + intro: 'Dies ist ein Beispiel-Memo zum Kennenlernen.', + transcript: + 'Memoro ist dein AI-gestützter Sprachrekorder und Memo-Manager. Nimm Gedanken auf, lass sie transkribieren und erstelle Erinnerungen daraus.', + audioDurationMs: null, + processingStatus: 'completed' as const, + isArchived: false, + isPinned: true, + isPublic: false, + blueprintId: null, + language: 'de', + }, + ], + memories: [ + { + id: 'demo-memory-1', + memoId: DEMO_MEMO_ID, + title: 'Kernfunktionen', + content: + 'Memoro bietet Sprachaufnahme, automatische Transkription, KI-gestützte Zusammenfassungen und Tagging.', + }, + ], + memoroTags: [ + { + id: 'tag-ideen', + name: 'Ideen', + color: '#3b82f6', + isPinned: true, + sortOrder: 0, + }, + { + id: 'tag-notizen', + name: 'Notizen', + color: '#10b981', + isPinned: false, + sortOrder: 1, + }, + ], + memoTags: [ + { + id: 'mt-demo-1', + memoId: DEMO_MEMO_ID, + tagId: 'tag-notizen', + }, + ], + memoroSpaces: [] as Record[], + spaceMembers: [] as Record[], + memoSpaces: [] as Record[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/memoro/index.ts b/apps/manacore/apps/web/src/lib/modules/memoro/index.ts new file mode 100644 index 000000000..4dd6b218a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/memoro/index.ts @@ -0,0 +1,49 @@ +/** + * Memoro module — barrel exports. + */ + +export { memosStore } from './stores/memos.svelte'; +export { tagsStore } from './stores/tags.svelte'; +export { memoriesStore } from './stores/memories.svelte'; +export { + useAllMemos, + useArchivedMemos, + useMemoriesByMemo, + useAllTags, + useAllMemoTags, + useAllSpaces, + toMemo, + toMemory, + toTag, + toSpace, + sortMemos, + filterBySearch, + filterByTag, + getTagsForMemo, + formatDuration, + getStatusLabel, +} from './queries'; +export { + memoTable, + memoryTable, + memoroTagTable, + memoTagTable, + memoroSpaceTable, + spaceMemberTable, + memoSpaceTable, + MEMORO_GUEST_SEED, +} from './collections'; +export type { + LocalMemo, + LocalMemory, + LocalTag, + LocalMemoTag, + LocalSpace, + LocalSpaceMember, + LocalMemoSpace, + Memo, + Memory, + Tag, + Space, + ProcessingStatus, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/memoro/queries.ts b/apps/manacore/apps/web/src/lib/modules/memoro/queries.ts new file mode 100644 index 000000000..459343f98 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/memoro/queries.ts @@ -0,0 +1,183 @@ +/** + * Reactive queries & pure helpers for Memoro — uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { + LocalMemo, + LocalMemory, + LocalTag, + LocalMemoTag, + LocalSpace, + Memo, + Memory, + Tag, + Space, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toMemo(local: LocalMemo): Memo { + return { + id: local.id, + title: local.title, + intro: local.intro, + transcript: local.transcript, + audioDurationMs: local.audioDurationMs, + processingStatus: local.processingStatus, + isArchived: local.isArchived, + isPinned: local.isPinned, + isPublic: local.isPublic, + language: local.language, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toMemory(local: LocalMemory): Memory { + return { + id: local.id, + memoId: local.memoId, + title: local.title, + content: local.content, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toTag(local: LocalTag): Tag { + return { + id: local.id, + name: local.name, + color: local.color, + isPinned: local.isPinned ?? false, + sortOrder: local.sortOrder ?? 0, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toSpace(local: LocalSpace): Space { + return { + id: local.id, + name: local.name, + description: local.description, + ownerId: local.ownerId, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +/** All non-archived memos, sorted by pinned first then createdAt desc. */ +export function useAllMemos() { + return liveQuery(async () => { + const locals = await db.table('memos').toArray(); + return sortMemos(locals.filter((m) => !m.deletedAt && !m.isArchived).map(toMemo)); + }); +} + +/** All archived memos, sorted by updatedAt desc. */ +export function useArchivedMemos() { + return liveQuery(async () => { + const locals = await db.table('memos').toArray(); + return locals + .filter((m) => !m.deletedAt && m.isArchived) + .map(toMemo) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + }); +} + +/** Memories for a specific memo. */ +export function useMemoriesByMemo(memoId: string) { + return liveQuery(async () => { + const locals = await db.table('memories').where('memoId').equals(memoId).toArray(); + return locals.filter((m) => !m.deletedAt).map(toMemory); + }); +} + +/** All tags, sorted by sortOrder. */ +export function useAllTags() { + return liveQuery(async () => { + const locals = await db.table('memoroTags').toArray(); + return locals + .filter((t) => !t.deletedAt) + .map(toTag) + .sort((a, b) => a.sortOrder - b.sortOrder); + }); +} + +/** All memo-tag associations. */ +export function useAllMemoTags() { + return liveQuery(async () => { + const locals = await db.table('memoTags').toArray(); + return locals.filter((mt) => !mt.deletedAt); + }); +} + +/** All spaces. */ +export function useAllSpaces() { + return liveQuery(async () => { + const locals = await db.table('memoroSpaces').toArray(); + return locals.filter((s) => !s.deletedAt).map(toSpace); + }); +} + +// ─── Pure Sort / Filter Functions ────────────────────────── + +/** Sort memos: pinned first, then by createdAt descending. */ +export function sortMemos(list: Memo[]): Memo[] { + return [...list].sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); +} + +/** Filter memos by search query on title and transcript. */ +export function filterBySearch(memos: Memo[], query: string): Memo[] { + if (!query.trim()) return memos; + const lower = query.toLowerCase(); + return memos.filter( + (m) => m.title?.toLowerCase().includes(lower) || m.transcript?.toLowerCase().includes(lower) + ); +} + +/** Filter memos by tag. */ +export function filterByTag(memos: Memo[], memoTags: LocalMemoTag[], tagId: string): Memo[] { + const memoIds = new Set(memoTags.filter((mt) => mt.tagId === tagId).map((mt) => mt.memoId)); + return memos.filter((m) => memoIds.has(m.id)); +} + +/** Get tags for a specific memo. */ +export function getTagsForMemo(tags: Tag[], memoTags: LocalMemoTag[], memoId: string): Tag[] { + const tagIds = new Set(memoTags.filter((mt) => mt.memoId === memoId).map((mt) => mt.tagId)); + return tags.filter((t) => tagIds.has(t.id)); +} + +/** Format audio duration in ms to readable string. */ +export function formatDuration(ms: number | null): string { + if (!ms) return ''; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +/** Get processing status label. */ +export function getStatusLabel(status: string): string { + switch (status) { + case 'pending': + return 'Ausstehend'; + case 'processing': + return 'Verarbeitung...'; + case 'completed': + return 'Fertig'; + case 'failed': + return 'Fehlgeschlagen'; + default: + return status; + } +} diff --git a/apps/manacore/apps/web/src/lib/modules/memoro/stores/memories.svelte.ts b/apps/manacore/apps/web/src/lib/modules/memoro/stores/memories.svelte.ts new file mode 100644 index 000000000..55f716822 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/memoro/stores/memories.svelte.ts @@ -0,0 +1,38 @@ +/** + * Memories Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * Handles memory (AI insight) CRUD. + */ + +import { memoryTable } from '../collections'; +import { toMemory } from '../queries'; +import type { LocalMemory } from '../types'; + +export const memoriesStore = { + /** Create a new memory for a memo. */ + async create(data: { memoId: string; title: string; content?: string }) { + const newLocal: LocalMemory = { + id: crypto.randomUUID(), + memoId: data.memoId, + title: data.title, + content: data.content ?? null, + }; + await memoryTable.add(newLocal); + return toMemory(newLocal); + }, + + /** Update a memory. */ + async update(id: string, data: Partial>) { + await memoryTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a memory. */ + async delete(id: string) { + const now = new Date().toISOString(); + await memoryTable.update(id, { deletedAt: now, updatedAt: now }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts b/apps/manacore/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts new file mode 100644 index 000000000..b6fb0b263 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts @@ -0,0 +1,85 @@ +/** + * Memos Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * Handles memo CRUD, archive, pin, delete. + */ + +import { memoTable } from '../collections'; +import { toMemo } from '../queries'; +import type { LocalMemo } from '../types'; + +export const memosStore = { + /** Create a new memo (e.g., after recording). */ + async create(data: { + title?: string; + transcript?: string; + language?: string; + blueprintId?: string; + }) { + const newLocal: LocalMemo = { + id: crypto.randomUUID(), + title: data.title ?? null, + intro: null, + transcript: data.transcript ?? null, + audioDurationMs: null, + processingStatus: data.transcript ? 'completed' : 'pending', + isArchived: false, + isPinned: false, + isPublic: false, + blueprintId: data.blueprintId ?? null, + language: data.language ?? null, + }; + await memoTable.add(newLocal); + return toMemo(newLocal); + }, + + /** Update a memo's fields. */ + async update( + id: string, + data: Partial> + ) { + await memoTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + /** Archive a memo. */ + async archive(id: string) { + await memoTable.update(id, { + isArchived: true, + updatedAt: new Date().toISOString(), + }); + }, + + /** Unarchive a memo. */ + async unarchive(id: string) { + await memoTable.update(id, { + isArchived: false, + updatedAt: new Date().toISOString(), + }); + }, + + /** Pin a memo. */ + async pin(id: string) { + await memoTable.update(id, { + isPinned: true, + updatedAt: new Date().toISOString(), + }); + }, + + /** Unpin a memo. */ + async unpin(id: string) { + await memoTable.update(id, { + isPinned: false, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a memo. */ + async delete(id: string) { + const now = new Date().toISOString(); + await memoTable.update(id, { deletedAt: now, updatedAt: now }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/memoro/stores/tags.svelte.ts b/apps/manacore/apps/web/src/lib/modules/memoro/stores/tags.svelte.ts new file mode 100644 index 000000000..263e92207 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/memoro/stores/tags.svelte.ts @@ -0,0 +1,71 @@ +/** + * Tags Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * Handles tag CRUD and memo-tag associations. + */ + +import { memoroTagTable, memoTagTable } from '../collections'; +import { toTag } from '../queries'; +import type { LocalTag, LocalMemoTag } from '../types'; + +export const tagsStore = { + /** Create a new tag. */ + async create(data: { name: string; color?: string }) { + const all = await memoroTagTable.toArray(); + const active = all.filter((t) => !t.deletedAt); + const newLocal: LocalTag = { + id: crypto.randomUUID(), + name: data.name, + color: data.color ?? null, + isPinned: false, + sortOrder: active.length, + }; + await memoroTagTable.add(newLocal); + return toTag(newLocal); + }, + + /** Update a tag. */ + async update(id: string, data: Partial>) { + await memoroTagTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a tag and its associations. */ + async delete(id: string) { + const now = new Date().toISOString(); + await memoroTagTable.update(id, { deletedAt: now, updatedAt: now }); + // Soft-delete associations + const allMT = await memoTagTable.where('tagId').equals(id).toArray(); + for (const mt of allMT) { + await memoTagTable.update(mt.id, { deletedAt: now, updatedAt: now }); + } + }, + + /** Add a tag to a memo. */ + async addToMemo(memoId: string, tagId: string) { + // Check if association already exists + const existing = await memoTagTable.toArray(); + if (existing.some((mt) => mt.memoId === memoId && mt.tagId === tagId && !mt.deletedAt)) { + return; + } + const newMT: LocalMemoTag = { + id: crypto.randomUUID(), + memoId, + tagId, + }; + await memoTagTable.add(newMT); + }, + + /** Remove a tag from a memo. */ + async removeFromMemo(memoId: string, tagId: string) { + const all = await memoTagTable.toArray(); + const toRemove = all.find((mt) => mt.memoId === memoId && mt.tagId === tagId && !mt.deletedAt); + if (toRemove) { + const now = new Date().toISOString(); + await memoTagTable.update(toRemove.id, { deletedAt: now, updatedAt: now }); + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/memoro/types.ts b/apps/manacore/apps/web/src/lib/modules/memoro/types.ts new file mode 100644 index 000000000..f99e33d48 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/memoro/types.ts @@ -0,0 +1,127 @@ +/** + * Memoro module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export interface LocalMemo extends BaseRecord { + userId?: string; + title: string | null; + intro: string | null; + transcript: string | null; + audioDurationMs: number | null; + processingStatus: ProcessingStatus; + isArchived: boolean; + isPinned: boolean; + isPublic: boolean; + blueprintId: string | null; + language: string | null; + location?: Record; + source?: { + audioPath?: string; + audioDeleted?: boolean; + audioDuration?: number; + transcript?: string; + utterances?: Array<{ + text: string; + offset?: number; + duration?: number; + speakerId?: string; + }>; + speakers?: Record; + speakerMap?: Record; + primaryLanguage?: string; + languages?: string[]; + processing?: { + transcription?: { status: ProcessingStatus }; + headlineAndIntro?: { status: ProcessingStatus }; + }; + recordingStartedAt?: string; + }; + metadata?: Record; +} + +export interface LocalMemory extends BaseRecord { + memoId: string; + userId?: string; + title: string; + content: string | null; + metadata?: Record; +} + +export interface LocalTag extends BaseRecord { + name: string; + color: string | null; + userId?: string; + isPinned?: boolean; + sortOrder?: number; +} + +export interface LocalMemoTag extends BaseRecord { + memoId: string; + tagId: string; +} + +export interface LocalSpace extends BaseRecord { + name: string; + description: string | null; + ownerId: string; +} + +export interface LocalSpaceMember extends BaseRecord { + spaceId: string; + userId: string; + role: 'owner' | 'member'; +} + +export interface LocalMemoSpace extends BaseRecord { + memoId: string; + spaceId: string; +} + +// ─── View Types ──────────────────────────────────────────── + +export interface Memo { + id: string; + title: string | null; + intro: string | null; + transcript: string | null; + audioDurationMs: number | null; + processingStatus: ProcessingStatus; + isArchived: boolean; + isPinned: boolean; + isPublic: boolean; + language: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Memory { + id: string; + memoId: string; + title: string; + content: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Tag { + id: string; + name: string; + color: string | null; + isPinned: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +export interface Space { + id: string; + name: string; + description: string | null; + ownerId: string; + createdAt: string; + updatedAt: string; +} diff --git a/apps/manacore/apps/web/src/lib/modules/mukke/collections.ts b/apps/manacore/apps/web/src/lib/modules/mukke/collections.ts new file mode 100644 index 000000000..130f6c400 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/mukke/collections.ts @@ -0,0 +1,41 @@ +/** + * Mukke module — collection accessors and guest seed data. + * + * Table names: songs, mukkePlaylists, playlistSongs, mukkeProjects, markers + */ + +import { db } from '$lib/data/database'; +import type { + LocalSong, + LocalPlaylist, + LocalPlaylistSong, + LocalProject, + LocalMarker, +} from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const songTable = db.table('songs'); +export const mukkePlaylistTable = db.table('mukkePlaylists'); +export const playlistSongTable = db.table('playlistSongs'); +export const mukkeProjectTable = db.table('mukkeProjects'); +export const markerTable = db.table('markers'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const DEMO_PLAYLIST_ID = 'demo-favorites'; + +export const MUKKE_GUEST_SEED = { + songs: [] as Record[], + mukkePlaylists: [ + { + id: DEMO_PLAYLIST_ID, + name: 'Meine Favoriten', + description: 'Deine Lieblingssongs.', + coverArtPath: null, + }, + ], + playlistSongs: [] as Record[], + mukkeProjects: [] as Record[], + markers: [] as Record[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/mukke/index.ts b/apps/manacore/apps/web/src/lib/modules/mukke/index.ts new file mode 100644 index 000000000..dd51f8318 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/mukke/index.ts @@ -0,0 +1,52 @@ +/** + * Mukke module — barrel exports. + */ + +export { libraryStore } from './stores/library.svelte'; +export { playlistsStore } from './stores/playlists.svelte'; +export { projectsStore } from './stores/projects.svelte'; +export { playerStore } from './stores/player.svelte'; +export { + useAllSongs, + useAllPlaylists, + useAllPlaylistSongs, + useAllProjects, + useMarkersByBeat, + toSong, + toPlaylist, + toProject, + searchSongs, + filterFavorites, + filterByArtist, + filterByAlbum, + filterByGenre, + getPlaylistSongs, + groupByArtist, + groupByAlbum, + groupByGenre, + computeStats, + formatDuration, +} from './queries'; +export { + songTable, + mukkePlaylistTable, + playlistSongTable, + mukkeProjectTable, + markerTable, + MUKKE_GUEST_SEED, +} from './collections'; +export type { + LocalSong, + LocalPlaylist, + LocalPlaylistSong, + LocalProject, + LocalMarker, + Song, + Playlist, + Project, + Album, + Artist, + Genre, + LibraryStats, + RepeatMode, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/mukke/queries.ts b/apps/manacore/apps/web/src/lib/modules/mukke/queries.ts new file mode 100644 index 000000000..ad3c56f4d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/mukke/queries.ts @@ -0,0 +1,247 @@ +/** + * Reactive queries & pure helpers for Mukke — uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { + LocalSong, + LocalPlaylist, + LocalPlaylistSong, + LocalProject, + LocalMarker, + Song, + Playlist, + Project, + Album, + Artist, + Genre, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toSong(local: LocalSong): Song { + return { + id: local.id, + title: local.title, + artist: local.artist ?? null, + album: local.album ?? null, + albumArtist: local.albumArtist ?? null, + genre: local.genre ?? null, + trackNumber: local.trackNumber ?? null, + year: local.year ?? null, + duration: local.duration ?? null, + storagePath: local.storagePath, + coverArtPath: local.coverArtPath ?? null, + fileSize: local.fileSize ?? null, + bpm: local.bpm ?? null, + favorite: local.favorite, + playCount: local.playCount, + lastPlayedAt: local.lastPlayedAt ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toPlaylist(local: LocalPlaylist): Playlist { + return { + id: local.id, + name: local.name, + description: local.description ?? null, + coverArtPath: local.coverArtPath ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toProject(local: LocalProject): Project { + return { + id: local.id, + title: local.title, + description: local.description ?? null, + songId: local.songId ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +/** All songs, sorted by title. */ +export function useAllSongs() { + return liveQuery(async () => { + const locals = await db.table('songs').toArray(); + return locals + .filter((s) => !s.deletedAt) + .map(toSong) + .sort((a, b) => a.title.localeCompare(b.title)); + }); +} + +/** All playlists, sorted by name. */ +export function useAllPlaylists() { + return liveQuery(async () => { + const locals = await db.table('mukkePlaylists').toArray(); + return locals + .filter((p) => !p.deletedAt) + .map(toPlaylist) + .sort((a, b) => a.name.localeCompare(b.name)); + }); +} + +/** All playlist-song associations. */ +export function useAllPlaylistSongs() { + return liveQuery(async () => { + const locals = await db.table('playlistSongs').toArray(); + return locals.filter((ps) => !ps.deletedAt); + }); +} + +/** All projects, sorted by title. */ +export function useAllProjects() { + return liveQuery(async () => { + const locals = await db.table('mukkeProjects').toArray(); + return locals + .filter((p) => !p.deletedAt) + .map(toProject) + .sort((a, b) => a.title.localeCompare(b.title)); + }); +} + +/** All markers for a given beat ID. */ +export function useMarkersByBeat(beatId: string) { + return liveQuery(async () => { + const locals = await db.table('markers').where('beatId').equals(beatId).toArray(); + return locals.filter((m) => !m.deletedAt).sort((a, b) => a.startTime - b.startTime); + }); +} + +// ─── Pure Filter Functions ───────────────────────────────── + +/** Filter songs by search query across title, artist, album. */ +export function searchSongs(songs: Song[], query: string): Song[] { + if (!query.trim()) return songs; + const search = query.toLowerCase().trim(); + return songs.filter( + (s) => + s.title?.toLowerCase().includes(search) || + s.artist?.toLowerCase().includes(search) || + s.album?.toLowerCase().includes(search) + ); +} + +/** Filter songs to favorites only. */ +export function filterFavorites(songs: Song[]): Song[] { + return songs.filter((s) => s.favorite); +} + +/** Filter songs by artist. */ +export function filterByArtist(songs: Song[], artist: string): Song[] { + if (!artist) return songs; + return songs.filter((s) => s.artist === artist); +} + +/** Filter songs by album. */ +export function filterByAlbum(songs: Song[], album: string): Song[] { + if (!album) return songs; + return songs.filter((s) => s.album === album); +} + +/** Filter songs by genre. */ +export function filterByGenre(songs: Song[], genre: string): Song[] { + if (!genre) return songs; + return songs.filter((s) => s.genre === genre); +} + +/** Get songs for a playlist, sorted by sortOrder. */ +export function getPlaylistSongs( + songs: Song[], + playlistSongs: LocalPlaylistSong[], + playlistId: string +): Song[] { + const psForPlaylist = playlistSongs + .filter((ps) => ps.playlistId === playlistId) + .sort((a, b) => a.sortOrder - b.sortOrder); + return psForPlaylist + .map((ps) => songs.find((s) => s.id === ps.songId)) + .filter((s): s is Song => !!s); +} + +/** Group songs by artist. */ +export function groupByArtist(songs: Song[]): Album[] { + const map = new Map(); + const artistAlbums = new Map>(); + for (const s of songs) { + const key = s.artist || 'Unknown'; + if (!map.has(key)) { + map.set(key, { songCount: 0, albumCount: 0 }); + artistAlbums.set(key, new Set()); + } + map.get(key)!.songCount++; + if (s.album) artistAlbums.get(key)!.add(s.album); + } + return Array.from(map.entries()).map(([artist, data]) => ({ + album: artist, + albumArtist: artist, + year: null, + coverArtPath: null, + songCount: data.songCount, + })); +} + +/** Group songs by album. */ +export function groupByAlbum(songs: Song[]): Album[] { + const albumMap = new Map(); + for (const s of songs) { + const key = s.album || 'Unknown Album'; + if (!albumMap.has(key)) { + albumMap.set(key, { + album: key, + albumArtist: s.albumArtist || s.artist || 'Unknown', + year: s.year ?? null, + coverArtPath: s.coverArtPath ?? null, + songCount: 0, + }); + } + albumMap.get(key)!.songCount++; + } + return Array.from(albumMap.values()); +} + +/** Group songs by genre. */ +export function groupByGenre(songs: Song[]): Genre[] { + const genreMap = new Map(); + for (const s of songs) { + const key = s.genre || 'Unknown'; + genreMap.set(key, (genreMap.get(key) || 0) + 1); + } + return Array.from(genreMap.entries()).map(([genre, songCount]) => ({ genre, songCount })); +} + +/** Compute library stats from songs. */ +export function computeStats(songs: Song[]): { + totalSongs: number; + totalArtists: number; + totalAlbums: number; + totalGenres: number; + totalDuration: number; + totalPlays: number; +} { + const artists = new Set(songs.map((s) => s.artist).filter(Boolean)); + const albums = new Set(songs.map((s) => s.album).filter(Boolean)); + const genres = new Set(songs.map((s) => s.genre).filter(Boolean)); + return { + totalSongs: songs.length, + totalArtists: artists.size, + totalAlbums: albums.size, + totalGenres: genres.size, + totalDuration: songs.reduce((sum, s) => sum + (s.duration || 0), 0), + totalPlays: songs.reduce((sum, s) => sum + (s.playCount || 0), 0), + }; +} + +/** Format duration in seconds to m:ss. */ +export function formatDuration(seconds: number | null | undefined): string { + if (!seconds) return '0:00'; + return Math.floor(seconds / 60) + ':' + String(Math.floor(seconds % 60)).padStart(2, '0'); +} diff --git a/apps/manacore/apps/web/src/lib/modules/mukke/stores/library.svelte.ts b/apps/manacore/apps/web/src/lib/modules/mukke/stores/library.svelte.ts new file mode 100644 index 000000000..5644917e2 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/mukke/stores/library.svelte.ts @@ -0,0 +1,61 @@ +/** + * Library Store — Mutations for songs + * + * Reads come from liveQuery hooks in queries.ts. + * Handles toggle favorite, delete, update metadata. + */ + +import { songTable } from '../collections'; +import type { LocalSong } from '../types'; + +export const libraryStore = { + /** Toggle favorite — writes to IndexedDB instantly. */ + async toggleFavorite(id: string) { + const local = await songTable.get(id); + if (local) { + await songTable.update(id, { + favorite: !local.favorite, + updatedAt: new Date().toISOString(), + }); + } + }, + + /** Increment play count. */ + async incrementPlayCount(id: string) { + const local = await songTable.get(id); + if (local) { + await songTable.update(id, { + playCount: (local.playCount || 0) + 1, + lastPlayedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + }, + + /** Update song metadata. */ + async updateMetadata( + id: string, + data: Partial< + Pick< + LocalSong, + 'title' | 'artist' | 'album' | 'albumArtist' | 'genre' | 'trackNumber' | 'year' | 'bpm' + > + > + ) { + await songTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a song. */ + async delete(id: string) { + const now = new Date().toISOString(); + await songTable.update(id, { deletedAt: now, updatedAt: now }); + }, + + /** Insert a song (e.g., after upload). */ + async insert(song: LocalSong) { + await songTable.add(song); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/mukke/stores/player.svelte.ts b/apps/manacore/apps/web/src/lib/modules/mukke/stores/player.svelte.ts new file mode 100644 index 000000000..41d4ed541 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/mukke/stores/player.svelte.ts @@ -0,0 +1,275 @@ +/** + * Player Store — Audio playback state management + * + * Manages the HTML5 Audio element, queue, shuffle, repeat modes. + * This is a runtime-only store (no IndexedDB persistence). + */ + +import type { Song, RepeatMode } from '../types'; + +interface PlayerState { + currentSong: Song | null; + isPlaying: boolean; + currentTime: number; + duration: number; + volume: number; + repeatMode: RepeatMode; + shuffleOn: boolean; + queue: Song[]; + originalQueue: Song[]; + currentIndex: number; + showFullPlayer: boolean; + error: string | null; +} + +function shuffleArray(arr: T[], keepIndex: number): T[] { + const result = [...arr]; + if (keepIndex >= 0 && keepIndex < result.length) { + [result[0], result[keepIndex]] = [result[keepIndex], result[0]]; + } + for (let i = result.length - 1; i > 1; i--) { + const j = 1 + Math.floor(Math.random() * i); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +function createPlayerStore() { + let state = $state({ + currentSong: null, + isPlaying: false, + currentTime: 0, + duration: 0, + volume: 1, + repeatMode: 'off', + shuffleOn: false, + queue: [], + originalQueue: [], + currentIndex: 0, + showFullPlayer: false, + error: null, + }); + + let audio: HTMLAudioElement | null = null; + + if (typeof window !== 'undefined') { + audio = new Audio(); + audio.crossOrigin = 'anonymous'; + audio.addEventListener('timeupdate', () => { + state.currentTime = audio!.currentTime; + }); + audio.addEventListener('loadedmetadata', () => { + state.duration = audio!.duration; + }); + audio.addEventListener('ended', () => { + handleNext(); + }); + audio.addEventListener('error', () => { + state.error = 'Audiodatei konnte nicht geladen werden'; + state.isPlaying = false; + }); + } + + function getNextIndex(): number | null { + if (state.queue.length === 0) return null; + if (state.repeatMode === 'one') return state.currentIndex; + if (state.currentIndex < state.queue.length - 1) return state.currentIndex + 1; + if (state.repeatMode === 'all') return 0; + return null; + } + + function getPreviousIndex(): number | null { + if (state.queue.length === 0) return null; + if (state.repeatMode === 'one') return state.currentIndex; + if (state.currentIndex > 0) return state.currentIndex - 1; + if (state.repeatMode === 'all') return state.queue.length - 1; + return null; + } + + function updateMediaSession(song: Song) { + if (typeof navigator !== 'undefined' && 'mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: song.title, + artist: song.artist || 'Unknown', + album: song.album || '', + }); + navigator.mediaSession.setActionHandler('play', () => store.togglePlay()); + navigator.mediaSession.setActionHandler('pause', () => store.togglePlay()); + navigator.mediaSession.setActionHandler('nexttrack', () => store.nextSong()); + navigator.mediaSession.setActionHandler('previoustrack', () => store.previousSong()); + } + } + + async function loadAndPlay(song: Song) { + if (!audio) return; + + state.currentSong = song; + state.currentTime = 0; + state.duration = 0; + state.error = null; + + // NOTE: In the unified app, audio URLs would come from the mukke backend + // via presigned S3 download URLs. For now, playback requires the backend. + // The store manages queue/state regardless. + try { + // Audio URL would be set here from backend + state.isPlaying = false; + updateMediaSession(song); + } catch (e) { + state.isPlaying = false; + state.error = 'Song konnte nicht abgespielt werden.'; + } + } + + function handleNext() { + const nextIdx = getNextIndex(); + if (nextIdx !== null) { + state.currentIndex = nextIdx; + loadAndPlay(state.queue[nextIdx]); + } else { + state.isPlaying = false; + if (audio) audio.pause(); + } + } + + const store = { + get currentSong() { + return state.currentSong; + }, + get isPlaying() { + return state.isPlaying; + }, + get currentTime() { + return state.currentTime; + }, + get duration() { + return state.duration; + }, + get volume() { + return state.volume; + }, + get repeatMode() { + return state.repeatMode; + }, + get shuffleOn() { + return state.shuffleOn; + }, + get queue() { + return state.queue; + }, + get currentIndex() { + return state.currentIndex; + }, + get showFullPlayer() { + return state.showFullPlayer; + }, + get error() { + return state.error; + }, + + async playSong(song: Song, queue?: Song[], startIndex?: number) { + if (queue) { + state.originalQueue = [...queue]; + state.queue = [...queue]; + state.currentIndex = startIndex ?? 0; + + if (state.shuffleOn) { + state.queue = shuffleArray(state.queue, state.currentIndex); + state.currentIndex = 0; + } + } + await loadAndPlay(song); + }, + + togglePlay() { + if (!audio || !state.currentSong) return; + if (state.isPlaying) { + audio.pause(); + state.isPlaying = false; + } else { + audio.play(); + state.isPlaying = true; + } + }, + + seekTo(time: number) { + if (!audio) return; + audio.currentTime = time; + state.currentTime = time; + }, + + setVolume(vol: number) { + if (!audio) return; + const clamped = Math.max(0, Math.min(1, vol)); + audio.volume = clamped; + state.volume = clamped; + }, + + nextSong() { + handleNext(); + }, + + previousSong() { + if (state.currentTime > 3) { + store.seekTo(0); + return; + } + const prevIdx = getPreviousIndex(); + if (prevIdx !== null) { + state.currentIndex = prevIdx; + loadAndPlay(state.queue[prevIdx]); + } + }, + + toggleShuffle() { + state.shuffleOn = !state.shuffleOn; + if (state.shuffleOn) { + state.queue = shuffleArray(state.queue, state.currentIndex); + state.currentIndex = 0; + } else { + const currentSong = state.queue[state.currentIndex]; + state.queue = [...state.originalQueue]; + const idx = state.queue.findIndex((s) => s.id === currentSong?.id); + state.currentIndex = idx >= 0 ? idx : 0; + } + }, + + toggleRepeat() { + const modes: RepeatMode[] = ['off', 'all', 'one']; + const currentIdx = modes.indexOf(state.repeatMode); + state.repeatMode = modes[(currentIdx + 1) % modes.length]; + }, + + toggleFullPlayer() { + state.showFullPlayer = !state.showFullPlayer; + }, + + clearQueue() { + if (audio) { + audio.pause(); + audio.src = ''; + } + state.currentSong = null; + state.isPlaying = false; + state.currentTime = 0; + state.duration = 0; + state.queue = []; + state.originalQueue = []; + state.currentIndex = 0; + state.showFullPlayer = false; + state.error = null; + }, + + clearError() { + state.error = null; + }, + + getAudioElement(): HTMLAudioElement | null { + return audio; + }, + }; + + return store; +} + +export const playerStore = createPlayerStore(); diff --git a/apps/manacore/apps/web/src/lib/modules/mukke/stores/playlists.svelte.ts b/apps/manacore/apps/web/src/lib/modules/mukke/stores/playlists.svelte.ts new file mode 100644 index 000000000..5e20a2893 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/mukke/stores/playlists.svelte.ts @@ -0,0 +1,81 @@ +/** + * Playlists Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * Handles playlist CRUD and song associations. + */ + +import { mukkePlaylistTable, playlistSongTable } from '../collections'; +import { toPlaylist } from '../queries'; +import type { LocalPlaylist, LocalPlaylistSong } from '../types'; + +export const playlistsStore = { + /** Create a new playlist. */ + async create(name: string, description?: string) { + const newLocal: LocalPlaylist = { + id: crypto.randomUUID(), + name, + description: description ?? null, + coverArtPath: null, + }; + await mukkePlaylistTable.add(newLocal); + return toPlaylist(newLocal); + }, + + /** Update a playlist. */ + async update(id: string, data: Partial>) { + await mukkePlaylistTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a playlist and its song associations. */ + async delete(id: string) { + const now = new Date().toISOString(); + await mukkePlaylistTable.update(id, { deletedAt: now, updatedAt: now }); + // Soft-delete associated playlistSongs + const allPS = await playlistSongTable.where('playlistId').equals(id).toArray(); + for (const ps of allPS) { + await playlistSongTable.update(ps.id, { deletedAt: now, updatedAt: now }); + } + }, + + /** Add a song to a playlist. */ + async addSong(playlistId: string, songId: string) { + const existing = await playlistSongTable.where('playlistId').equals(playlistId).toArray(); + const maxSort = existing + .filter((ps) => !ps.deletedAt) + .reduce((max, ps) => Math.max(max, ps.sortOrder), -1); + + const newPS: LocalPlaylistSong = { + id: crypto.randomUUID(), + playlistId, + songId, + sortOrder: maxSort + 1, + }; + await playlistSongTable.add(newPS); + }, + + /** Remove a song from a playlist. */ + async removeSong(playlistId: string, songId: string) { + const allPS = await playlistSongTable.where('playlistId').equals(playlistId).toArray(); + const toRemove = allPS.find((ps) => ps.songId === songId && !ps.deletedAt); + if (toRemove) { + const now = new Date().toISOString(); + await playlistSongTable.update(toRemove.id, { deletedAt: now, updatedAt: now }); + } + }, + + /** Reorder songs in a playlist. */ + async reorderSongs(playlistId: string, songIds: string[]) { + const allPS = await playlistSongTable.where('playlistId').equals(playlistId).toArray(); + const now = new Date().toISOString(); + for (let i = 0; i < songIds.length; i++) { + const ps = allPS.find((p) => p.songId === songIds[i] && !p.deletedAt); + if (ps) { + await playlistSongTable.update(ps.id, { sortOrder: i, updatedAt: now }); + } + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/mukke/stores/projects.svelte.ts b/apps/manacore/apps/web/src/lib/modules/mukke/stores/projects.svelte.ts new file mode 100644 index 000000000..9f898629d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/mukke/stores/projects.svelte.ts @@ -0,0 +1,38 @@ +/** + * Projects Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * Handles project CRUD. + */ + +import { mukkeProjectTable } from '../collections'; +import { toProject } from '../queries'; +import type { LocalProject } from '../types'; + +export const projectsStore = { + /** Create a new project. */ + async create(data: { title: string; description?: string; songId?: string }) { + const newLocal: LocalProject = { + id: crypto.randomUUID(), + title: data.title, + description: data.description ?? null, + songId: data.songId ?? null, + }; + await mukkeProjectTable.add(newLocal); + return toProject(newLocal); + }, + + /** Update a project. */ + async update(id: string, data: Partial>) { + await mukkeProjectTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + /** Soft-delete a project. */ + async delete(id: string) { + const now = new Date().toISOString(); + await mukkeProjectTable.update(id, { deletedAt: now, updatedAt: now }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/mukke/types.ts b/apps/manacore/apps/web/src/lib/modules/mukke/types.ts new file mode 100644 index 000000000..8e0d4e42c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/mukke/types.ts @@ -0,0 +1,122 @@ +/** + * Mukke module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalSong extends BaseRecord { + title: string; + artist?: string | null; + album?: string | null; + albumArtist?: string | null; + genre?: string | null; + trackNumber?: number | null; + year?: number | null; + duration?: number | null; + storagePath: string; + coverArtPath?: string | null; + fileSize?: number | null; + bpm?: number | null; + favorite: boolean; + playCount: number; + lastPlayedAt?: string | null; +} + +export interface LocalPlaylist extends BaseRecord { + name: string; + description?: string | null; + coverArtPath?: string | null; +} + +export interface LocalPlaylistSong extends BaseRecord { + playlistId: string; + songId: string; + sortOrder: number; +} + +export interface LocalProject extends BaseRecord { + title: string; + description?: string | null; + songId?: string | null; +} + +export interface LocalMarker extends BaseRecord { + beatId: string; + type: 'verse' | 'hook' | 'bridge' | 'intro' | 'outro' | 'drop' | 'breakdown' | 'custom'; + label?: string | null; + startTime: number; + endTime?: number | null; + color?: string | null; + sortOrder: number; +} + +// ─── View Types ──────────────────────────────────────────── + +export interface Song { + id: string; + title: string; + artist?: string | null; + album?: string | null; + albumArtist?: string | null; + genre?: string | null; + trackNumber?: number | null; + year?: number | null; + duration?: number | null; + storagePath: string; + coverArtPath?: string | null; + fileSize?: number | null; + bpm?: number | null; + favorite: boolean; + playCount: number; + lastPlayedAt?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Playlist { + id: string; + name: string; + description?: string | null; + coverArtPath?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Project { + id: string; + title: string; + description?: string | null; + songId?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Album { + album: string; + albumArtist: string; + year: number | null; + coverArtPath: string | null; + songCount: number; +} + +export interface Artist { + artist: string; + songCount: number; + albumCount: number; +} + +export interface Genre { + genre: string; + songCount: number; +} + +export interface LibraryStats { + totalSongs: number; + totalArtists: number; + totalAlbums: number; + totalGenres: number; + totalDuration: number; + totalPlays: number; +} + +export type RepeatMode = 'off' | 'all' | 'one'; diff --git a/apps/manacore/apps/web/src/lib/modules/picture/collections.ts b/apps/manacore/apps/web/src/lib/modules/picture/collections.ts new file mode 100644 index 000000000..63071d282 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/picture/collections.ts @@ -0,0 +1,83 @@ +/** + * Picture module — collection accessors and guest seed data. + * + * Uses prefixed table names in the unified DB: pictureTags (not 'tags'). + */ + +import { db } from '$lib/data/database'; +import type { + LocalImage, + LocalBoard, + LocalBoardItem, + LocalPictureTag, + LocalImageTag, +} from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const imageTable = db.table('images'); +export const boardTable = db.table('boards'); +export const boardItemTable = db.table('boardItems'); +export const pictureTagTable = db.table('pictureTags'); +export const imageTagTable = db.table('imageTags'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const DEMO_BOARD_ID = 'demo-moodboard'; + +export const PICTURE_GUEST_SEED = { + boards: [ + { + id: DEMO_BOARD_ID, + name: 'Willkommen bei Picture', + description: 'Dein erstes Moodboard — erstelle eigene Bilder mit KI!', + canvasWidth: 2000, + canvasHeight: 1500, + backgroundColor: '#1e1e2e', + isPublic: false, + }, + ] satisfies LocalBoard[], + boardItems: [ + { + id: 'text-welcome', + boardId: DEMO_BOARD_ID, + itemType: 'text' as const, + textContent: 'Willkommen bei Picture!', + fontSize: 48, + color: '#ffffff', + positionX: 600, + positionY: 200, + scaleX: 1, + scaleY: 1, + rotation: 0, + zIndex: 10, + opacity: 1, + width: 800, + height: null, + properties: { fontFamily: 'Arial', fontWeight: 'bold', textAlign: 'center' }, + }, + { + id: 'text-hint-1', + boardId: DEMO_BOARD_ID, + itemType: 'text' as const, + textContent: 'Erstelle KI-Bilder mit einem Prompt', + fontSize: 24, + color: '#a0a0c0', + positionX: 650, + positionY: 400, + scaleX: 1, + scaleY: 1, + rotation: 0, + zIndex: 9, + opacity: 1, + width: 700, + height: null, + properties: { fontFamily: 'Arial', fontWeight: 'normal', textAlign: 'center' }, + }, + ] satisfies LocalBoardItem[], + pictureTags: [ + { id: 'tag-landscape', name: 'Landschaft', color: '#22c55e' }, + { id: 'tag-portrait', name: 'Portrait', color: '#3b82f6' }, + { id: 'tag-abstract', name: 'Abstrakt', color: '#a855f7' }, + ] satisfies LocalPictureTag[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/picture/index.ts b/apps/manacore/apps/web/src/lib/modules/picture/index.ts new file mode 100644 index 000000000..c7500ef5b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/picture/index.ts @@ -0,0 +1,41 @@ +/** + * Picture module — barrel exports. + */ + +export { imagesStore } from './stores/images.svelte'; +export { boardsStore } from './stores/boards.svelte'; +export { pictureViewStore } from './stores/view.svelte'; +export { + useAllImages, + useArchivedImages, + useAllBoards, + useAllPictureTags, + useAllImageTags, + allImages$, + allBoards$, + toImage, + toBoard, + getFavoriteImages, + getImagesByTags, + findImageById, + findBoardById, +} from './queries'; +export { + imageTable, + boardTable, + boardItemTable, + pictureTagTable, + imageTagTable, + PICTURE_GUEST_SEED, +} from './collections'; +export type { + LocalImage, + LocalBoard, + LocalBoardItem, + LocalPictureTag, + LocalImageTag, + ViewMode, + Image, + Board, + BoardWithCount, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/picture/queries.ts b/apps/manacore/apps/web/src/lib/modules/picture/queries.ts new file mode 100644 index 000000000..8c2dc3d8a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/picture/queries.ts @@ -0,0 +1,178 @@ +/** + * Reactive Queries & Pure Helpers for Picture 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 { + LocalImage, + LocalBoard, + LocalBoardItem, + LocalPictureTag, + LocalImageTag, + Image, + Board, + BoardWithCount, +} from './types'; + +// ─── Type Converters ────────────────────────────────────── + +export function toImage(local: LocalImage): Image { + return { + id: local.id, + prompt: local.prompt, + negativePrompt: local.negativePrompt ?? undefined, + model: local.model ?? undefined, + style: local.style ?? undefined, + publicUrl: local.publicUrl ?? undefined, + storagePath: local.storagePath, + filename: local.filename, + format: local.format ?? undefined, + width: local.width ?? undefined, + height: local.height ?? undefined, + fileSize: local.fileSize ?? undefined, + blurhash: local.blurhash ?? undefined, + isPublic: local.isPublic, + isFavorite: local.isFavorite, + downloadCount: local.downloadCount, + rating: local.rating ?? undefined, + archivedAt: local.archivedAt ?? undefined, + generationId: local.generationId ?? undefined, + sourceImageId: local.sourceImageId ?? undefined, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toBoard(local: LocalBoard): Board { + return { + id: local.id, + name: local.name, + description: local.description ?? undefined, + thumbnailUrl: local.thumbnailUrl ?? undefined, + canvasWidth: local.canvasWidth, + canvasHeight: local.canvasHeight, + backgroundColor: local.backgroundColor, + isPublic: local.isPublic, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Svelte 5 Reactive Hooks (call during component init) ── + +/** All non-archived images, sorted by createdAt desc. */ +export function useAllImages() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('images').toArray(); + return locals + .filter((img) => !img.archivedAt && !img.deletedAt) + .map(toImage) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, [] as Image[]); +} + +/** All archived images, sorted by createdAt desc. */ +export function useArchivedImages() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('images').toArray(); + return locals + .filter((img) => !!img.archivedAt && !img.deletedAt) + .map(toImage) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, [] as Image[]); +} + +/** All boards with item counts, sorted by updatedAt desc. */ +export function useAllBoards() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('boards').toArray(); + const allItems = await db.table('boardItems').toArray(); + + const itemCounts = new Map(); + for (const item of allItems) { + if (!item.deletedAt) { + itemCounts.set(item.boardId, (itemCounts.get(item.boardId) || 0) + 1); + } + } + + return locals + .filter((b) => !b.deletedAt) + .map( + (local): BoardWithCount => ({ + ...toBoard(local), + itemCount: itemCounts.get(local.id) || 0, + }) + ) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + }, [] as BoardWithCount[]); +} + +/** All picture tags. */ +export function useAllPictureTags() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('pictureTags').toArray(); + return locals.filter((t) => !t.deletedAt); + }, [] as LocalPictureTag[]); +} + +/** All image-tag associations. */ +export function useAllImageTags() { + return useLiveQueryWithDefault(async () => { + return await db.table('imageTags').toArray(); + }, [] as LocalImageTag[]); +} + +// ─── Raw Observable Queries ──────────────────────────────── + +export function allImages$() { + return liveQuery(async () => { + const locals = await db.table('images').toArray(); + return locals + .filter((img) => !img.archivedAt && !img.deletedAt) + .map(toImage) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }); +} + +export function allBoards$() { + return liveQuery(async () => { + const locals = await db.table('boards').toArray(); + return locals.filter((b) => !b.deletedAt).map(toBoard); + }); +} + +// ─── Pure Helper Functions (for $derived) ───────────────── + +/** Filter images by favorites only. */ +export function getFavoriteImages(images: Image[]): Image[] { + return images.filter((img) => img.isFavorite); +} + +/** Filter images by tag IDs using image-tag associations. */ +export function getImagesByTags( + images: Image[], + imageTags: { imageId: string; tagId: string }[], + selectedTagIds: string[] +): Image[] { + if (selectedTagIds.length === 0) return images; + const imageIdsWithTags = new Set( + imageTags.filter((it) => selectedTagIds.includes(it.tagId)).map((it) => it.imageId) + ); + return images.filter((img) => imageIdsWithTags.has(img.id)); +} + +/** Find an image by ID. */ +export function findImageById(images: Image[], id: string): Image | undefined { + return images.find((img) => img.id === id); +} + +/** Find a board by ID. */ +export function findBoardById(boards: BoardWithCount[], id: string): BoardWithCount | undefined { + return boards.find((b) => b.id === id); +} diff --git a/apps/manacore/apps/web/src/lib/modules/picture/stores/boards.svelte.ts b/apps/manacore/apps/web/src/lib/modules/picture/stores/boards.svelte.ts new file mode 100644 index 000000000..d466b7b26 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/picture/stores/boards.svelte.ts @@ -0,0 +1,148 @@ +/** + * Boards Store — Mutation-Only Service + * + * All reads are handled by liveQuery hooks in queries.ts. + * This store only provides write operations (create, update, delete, duplicate). + * IndexedDB writes automatically trigger UI updates via Dexie liveQuery. + */ + +import { db } from '$lib/data/database'; +import type { LocalBoard, LocalBoardItem } from '../types'; +import { toBoard } from '../queries'; + +let error = $state(null); +let showCreateModal = $state(false); + +export const boardsStore = { + get error() { + return error; + }, + get showCreateModal() { + return showCreateModal; + }, + + setShowCreateModal(show: boolean) { + showCreateModal = show; + }, + + /** + * Create a new board -- writes to IndexedDB instantly. + */ + async createBoard(input: { name: string; description?: string; backgroundColor?: string }) { + error = null; + try { + const newLocal: LocalBoard = { + id: crypto.randomUUID(), + name: input.name, + description: input.description || null, + canvasWidth: 2000, + canvasHeight: 1500, + backgroundColor: input.backgroundColor || '#ffffff', + isPublic: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.table('boards').add(newLocal); + return { success: true, data: toBoard(newLocal) }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create board'; + return { success: false, error }; + } + }, + + /** + * Update a board -- writes to IndexedDB instantly. + */ + async updateBoard(id: string, input: Partial>) { + error = null; + try { + await db.table('boards').update(id, { + ...input, + updatedAt: new Date().toISOString(), + }); + const updated = await db.table('boards').get(id); + if (updated) { + return { success: true, data: toBoard(updated) }; + } + return { success: false, error: 'Board not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update board'; + return { success: false, error }; + } + }, + + /** + * Delete a board and all its items -- soft-deletes from IndexedDB instantly. + */ + async deleteBoard(id: string) { + error = null; + try { + const now = new Date().toISOString(); + // Soft-delete all board items + const items = await db + .table('boardItems') + .where('boardId') + .equals(id) + .toArray(); + for (const item of items) { + await db.table('boardItems').update(item.id, { deletedAt: now, updatedAt: now }); + } + // Soft-delete the board + await db.table('boards').update(id, { deletedAt: now, updatedAt: now }); + return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete board'; + return { success: false, error }; + } + }, + + /** + * Duplicate a board and all its items. + */ + async duplicateBoard(id: string) { + error = null; + try { + const original = await db.table('boards').get(id); + if (!original) return { success: false, error: 'Board not found' }; + + const newId = crypto.randomUUID(); + const now = new Date().toISOString(); + + const duplicated: LocalBoard = { + id: newId, + name: `${original.name} (Kopie)`, + description: original.description, + canvasWidth: original.canvasWidth, + canvasHeight: original.canvasHeight, + backgroundColor: original.backgroundColor, + isPublic: false, + createdAt: now, + updatedAt: now, + }; + await db.table('boards').add(duplicated); + + // Duplicate board items + const originalItems = await db + .table('boardItems') + .where('boardId') + .equals(id) + .toArray(); + for (const item of originalItems) { + if (item.deletedAt) continue; + await db.table('boardItems').add({ + ...item, + id: crypto.randomUUID(), + boardId: newId, + createdAt: now, + updatedAt: now, + }); + } + + return { success: true, data: toBoard(duplicated) }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to duplicate board'; + return { success: false, error }; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/picture/stores/images.svelte.ts b/apps/manacore/apps/web/src/lib/modules/picture/stores/images.svelte.ts new file mode 100644 index 000000000..189703eae --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/picture/stores/images.svelte.ts @@ -0,0 +1,103 @@ +/** + * Images 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 { LocalImage } from '../types'; +import { toImage } from '../queries'; + +let error = $state(null); +let selectedImageId = $state(null); +let showFavoritesOnly = $state(false); + +export const imagesStore = { + get error() { + return error; + }, + get selectedImageId() { + return selectedImageId; + }, + get showFavoritesOnly() { + return showFavoritesOnly; + }, + + setSelectedImage(id: string | null) { + selectedImageId = id; + }, + + toggleFavoritesFilter() { + showFavoritesOnly = !showFavoritesOnly; + }, + + /** + * Toggle favorite status of an image. + */ + async toggleFavorite(id: string, currentIsFavorite: boolean) { + error = null; + try { + await db.table('images').update(id, { + isFavorite: !currentIsFavorite, + updatedAt: new Date().toISOString(), + }); + return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to toggle favorite'; + return { success: false, error }; + } + }, + + /** + * Archive an image. + */ + async archiveImage(id: string) { + error = null; + try { + await db.table('images').update(id, { + archivedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to archive image'; + return { success: false, error }; + } + }, + + /** + * Restore an archived image. + */ + async restoreImage(id: string) { + error = null; + try { + await db.table('images').update(id, { + archivedAt: null, + updatedAt: new Date().toISOString(), + }); + return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to restore image'; + return { success: false, error }; + } + }, + + /** + * Delete an image -- soft-deletes from IndexedDB instantly. + */ + async deleteImage(id: string) { + error = null; + try { + await db.table('images').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete image'; + return { success: false, error }; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/picture/stores/view.svelte.ts b/apps/manacore/apps/web/src/lib/modules/picture/stores/view.svelte.ts new file mode 100644 index 000000000..42671a05a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/picture/stores/view.svelte.ts @@ -0,0 +1,39 @@ +/** + * Picture View Store — Manages gallery view mode (grid size). + */ + +import { browser } from '$app/environment'; +import type { ViewMode } from '../types'; + +const VIEW_MODE_KEY = 'manacore-picture-view-mode'; + +let viewMode = $state('grid3'); + +export const pictureViewStore = { + get viewMode() { + return viewMode; + }, + + initialize() { + if (!browser) return; + const saved = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null; + if (saved) viewMode = saved; + }, + + setViewMode(mode: ViewMode) { + viewMode = mode; + if (browser) { + localStorage.setItem(VIEW_MODE_KEY, mode); + } + }, + + cycleViewMode() { + const modes: ViewMode[] = ['single', 'grid3', 'grid5']; + const currentIndex = modes.indexOf(viewMode); + const nextMode = modes[(currentIndex + 1) % modes.length]; + viewMode = nextMode; + if (browser) { + localStorage.setItem(VIEW_MODE_KEY, nextMode); + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/picture/types.ts b/apps/manacore/apps/web/src/lib/modules/picture/types.ts new file mode 100644 index 000000000..fb4ba801e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/picture/types.ts @@ -0,0 +1,110 @@ +/** + * Picture module types for the unified ManaCore app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalImage extends BaseRecord { + prompt: string; + negativePrompt?: string | null; + model?: string | null; + style?: string | null; + publicUrl?: string | null; + storagePath: string; + filename: string; + format?: string | null; + width?: number | null; + height?: number | null; + fileSize?: number | null; + blurhash?: string | null; + isPublic: boolean; + isFavorite: boolean; + downloadCount: number; + rating?: number | null; + archivedAt?: string | null; + generationId?: string | null; + sourceImageId?: string | null; +} + +export interface LocalBoard extends BaseRecord { + name: string; + description?: string | null; + thumbnailUrl?: string | null; + canvasWidth: number; + canvasHeight: number; + backgroundColor: string; + isPublic: boolean; +} + +export interface LocalBoardItem extends BaseRecord { + boardId: string; + itemType: 'image' | 'text'; + imageId?: string | null; + textContent?: string | null; + fontSize?: number | null; + color?: string | null; + positionX: number; + positionY: number; + scaleX: number; + scaleY: number; + rotation: number; + zIndex: number; + opacity: number; + width?: number | null; + height?: number | null; + properties: Record; +} + +export interface LocalPictureTag extends BaseRecord { + name: string; + color?: string | null; +} + +export interface LocalImageTag extends BaseRecord { + imageId: string; + tagId: string; +} + +export type ViewMode = 'single' | 'grid3' | 'grid5'; + +export interface Image { + id: string; + prompt: string; + negativePrompt?: string; + model?: string; + style?: string; + publicUrl?: string; + storagePath: string; + filename: string; + format?: string; + width?: number; + height?: number; + fileSize?: number; + blurhash?: string; + isPublic: boolean; + isFavorite: boolean; + downloadCount: number; + rating?: number; + archivedAt?: string; + generationId?: string; + sourceImageId?: string; + createdAt: string; + updatedAt: string; +} + +export interface Board { + id: string; + name: string; + description?: string; + thumbnailUrl?: string; + canvasWidth: number; + canvasHeight: number; + backgroundColor: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; +} + +export interface BoardWithCount extends Board { + itemCount: number; +} diff --git a/apps/manacore/apps/web/src/lib/modules/todo/collections.ts b/apps/manacore/apps/web/src/lib/modules/todo/collections.ts new file mode 100644 index 000000000..6d4237770 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/collections.ts @@ -0,0 +1,149 @@ +/** + * Todo module — collection accessors and guest seed data. + * + * Uses tables in the unified DB: tasks, todoProjects, labels, taskLabels, reminders, boardViews. + */ + +import { db } from '$lib/data/database'; +import type { + LocalTask, + LocalLabel, + LocalTaskLabel, + LocalReminder, + LocalBoardView, + LocalTodoProject, +} from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const taskTable = db.table('tasks'); +export const todoProjectTable = db.table('todoProjects'); +export const labelTable = db.table('labels'); +export const taskLabelTable = db.table('taskLabels'); +export const reminderTable = db.table('reminders'); +export const boardViewTable = db.table('boardViews'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const now = new Date(); +const tomorrow = new Date(now); +tomorrow.setDate(tomorrow.getDate() + 1); +const nextWeek = new Date(now); +nextWeek.setDate(nextWeek.getDate() + 7); + +export const TODO_GUEST_SEED = { + labels: [ + { + id: 'label-important', + name: 'Wichtig', + color: '#ef4444', + }, + { + id: 'label-idea', + name: 'Idee', + color: '#f59e0b', + }, + ] satisfies LocalLabel[], + + boardViews: [ + { + id: 'view-kanban', + name: 'Kanban', + icon: 'columns', + groupBy: 'status' as const, + layout: 'kanban' as const, + order: 0, + columns: [ + { + id: 'col-todo', + name: 'To Do', + color: '#6B7280', + match: { type: 'status' as const, value: 'pending' }, + onDrop: { setCompleted: false }, + }, + { + id: 'col-done', + name: 'Erledigt', + color: '#22C55E', + match: { type: 'status' as const, value: 'completed' }, + onDrop: { setCompleted: true }, + }, + ], + }, + { + id: 'view-priority', + name: 'Prioritaet', + icon: 'flag', + groupBy: 'priority' as const, + layout: 'kanban' as const, + order: 1, + columns: [ + { + id: 'col-pri-urgent', + name: 'Dringend', + color: '#EF4444', + match: { type: 'priority' as const, value: 'urgent' }, + onDrop: { setPriority: 'urgent' as const }, + }, + { + id: 'col-pri-high', + name: 'Hoch', + color: '#F59E0B', + match: { type: 'priority' as const, value: 'high' }, + onDrop: { setPriority: 'high' as const }, + }, + { + id: 'col-pri-medium', + name: 'Mittel', + color: '#3B82F6', + match: { type: 'priority' as const, value: 'medium' }, + onDrop: { setPriority: 'medium' as const }, + }, + { + id: 'col-pri-low', + name: 'Niedrig', + color: '#6B7280', + match: { type: 'priority' as const, value: 'low' }, + onDrop: { setPriority: 'low' as const }, + }, + ], + }, + ] satisfies LocalBoardView[], + + tasks: [ + { + id: 'onboard-1', + title: 'Willkommen bei Todo! Tippe hier, um diese Aufgabe zu bearbeiten', + description: 'Du kannst Titel, Beschreibung, Prioritaet und Faelligkeitsdatum aendern.', + priority: 'medium' as const, + isCompleted: false, + order: 0, + subtasks: [ + { id: 'sub-1', title: 'Titel bearbeiten', isCompleted: false, order: 0 }, + { id: 'sub-2', title: 'Beschreibung hinzufuegen', isCompleted: false, order: 1 }, + { id: 'sub-3', title: 'Prioritaet aendern', isCompleted: false, order: 2 }, + ], + }, + { + id: 'onboard-2', + title: 'Klicke den Kreis links, um diese Aufgabe abzuschliessen', + priority: 'low' as const, + isCompleted: false, + order: 1, + }, + { + id: 'sample-1', + title: 'Einkaufen gehen', + description: 'Milch, Brot, Obst', + priority: 'medium' as const, + isCompleted: false, + dueDate: tomorrow.toISOString(), + order: 2, + subtasks: [ + { id: 'shop-1', title: 'Milch', isCompleted: false, order: 0 }, + { id: 'shop-2', title: 'Brot', isCompleted: false, order: 1 }, + { id: 'shop-3', title: 'Obst', isCompleted: false, order: 2 }, + ], + }, + ] satisfies LocalTask[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/index.ts b/apps/manacore/apps/web/src/lib/modules/todo/index.ts new file mode 100644 index 000000000..9f9dd0eb0 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/index.ts @@ -0,0 +1,55 @@ +/** + * Todo module — barrel exports. + */ + +export { tasksStore } from './stores/tasks.svelte'; +export { boardViewsStore } from './stores/board-views.svelte'; +export { viewStore } from './stores/view.svelte'; +export { labelsStore } from './stores/labels.svelte'; +export { + useAllTasks, + useAllLabels, + useAllBoardViews, + useAllReminders, + useAllProjects, + toTask, + filterIncomplete, + filterCompleted, + filterOverdue, + filterToday, + filterUpcoming, + filterByProject, + searchTasks, + sortTasks, + getPriorityLabel, + getPriorityColor, + getTaskStats, +} from './queries'; +export { + taskTable, + todoProjectTable, + labelTable, + taskLabelTable, + reminderTable, + boardViewTable, + TODO_GUEST_SEED, +} from './collections'; +export type { + LocalTask, + LocalLabel, + LocalTaskLabel, + LocalReminder, + LocalBoardView, + LocalTodoProject, + Task, + TaskPriority, + TaskStatus, + Subtask, + ViewType, + SortBy, + SortOrder, + ViewColumn, + TaskMatcher, + DropAction, + ViewFilter, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/queries.ts b/apps/manacore/apps/web/src/lib/modules/todo/queries.ts new file mode 100644 index 000000000..31494d96f --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/queries.ts @@ -0,0 +1,207 @@ +/** + * Reactive queries & pure helpers for Todo — uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { + LocalTask, + LocalLabel, + LocalBoardView, + LocalReminder, + LocalTodoProject, + Task, + TaskPriority, + Subtask, +} from './types'; + +// ─── Type Converter ─────────────────────────────────────── + +export function toTask(local: LocalTask): Task { + return { + id: local.id, + projectId: (local as Record).projectId as string | null | undefined, + userId: local.userId ?? 'guest', + title: local.title, + description: local.description, + dueDate: local.dueDate, + scheduledDate: local.scheduledDate, + scheduledStartTime: local.scheduledStartTime, + estimatedDuration: local.estimatedDuration, + priority: local.priority, + status: local.isCompleted ? 'completed' : 'pending', + isCompleted: local.isCompleted, + completedAt: local.completedAt, + order: local.order, + recurrenceRule: local.recurrenceRule, + subtasks: local.subtasks ?? null, + metadata: local.metadata ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllTasks() { + return liveQuery(async () => { + const locals = await db.table('tasks').orderBy('order').toArray(); + return locals.filter((t) => !t.deletedAt).map(toTask); + }); +} + +export function useAllLabels() { + return liveQuery(async () => { + const locals = await db.table('labels').toArray(); + return locals.filter((l) => !l.deletedAt); + }); +} + +export function useAllBoardViews() { + return liveQuery(async () => { + const locals = await db.table('boardViews').orderBy('order').toArray(); + return locals.filter((v) => !v.deletedAt); + }); +} + +export function useAllReminders() { + return liveQuery(async () => { + const locals = await db.table('reminders').toArray(); + return locals.filter((r) => !r.deletedAt); + }); +} + +export function useAllProjects() { + return liveQuery(async () => { + const locals = await db.table('todoProjects').orderBy('order').toArray(); + return locals.filter((p) => !p.deletedAt); + }); +} + +// ─── Pure Filter Functions ──────────────────────────────── + +export function filterIncomplete(tasks: Task[]): Task[] { + return tasks.filter((t) => !t.isCompleted); +} + +export function filterCompleted(tasks: Task[]): Task[] { + return tasks.filter((t) => t.isCompleted); +} + +export function filterOverdue(tasks: Task[]): Task[] { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + return tasks.filter((t) => { + if (!t.dueDate || t.isCompleted) return false; + const dueDate = new Date(t.dueDate); + return dueDate < todayStart; + }); +} + +export function filterToday(tasks: Task[]): Task[] { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const todayEnd = new Date(todayStart); + todayEnd.setDate(todayEnd.getDate() + 1); + + return tasks.filter((t) => { + if (t.isCompleted) return false; + if (!t.dueDate) return false; + const d = new Date(t.dueDate); + return d >= todayStart && d < todayEnd; + }); +} + +export function filterUpcoming(tasks: Task[]): Task[] { + const now = new Date(); + const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + const weekEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 8); + + return tasks.filter((t) => { + if (!t.dueDate || t.isCompleted) return false; + const d = new Date(t.dueDate); + return d >= todayEnd && d < weekEnd; + }); +} + +export function filterByProject(tasks: Task[], projectId: string | null): Task[] { + if (projectId === null) return tasks.filter((t) => !t.projectId); + return tasks.filter((t) => t.projectId === projectId); +} + +export function searchTasks(tasks: Task[], query: string): Task[] { + if (!query.trim()) return tasks; + const q = query.toLowerCase().trim(); + return tasks.filter( + (t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q) + ); +} + +export function sortTasks(tasks: Task[], by: string, order: 'asc' | 'desc' = 'asc'): Task[] { + return [...tasks].sort((a, b) => { + let cmp = 0; + switch (by) { + case 'dueDate': { + const aDate = a.dueDate ? new Date(a.dueDate).getTime() : Infinity; + const bDate = b.dueDate ? new Date(b.dueDate).getTime() : Infinity; + cmp = aDate - bDate; + break; + } + case 'priority': { + const pMap: Record = { + urgent: 0, + high: 1, + medium: 2, + low: 3, + }; + cmp = pMap[a.priority] - pMap[b.priority]; + break; + } + case 'title': + cmp = a.title.localeCompare(b.title); + break; + case 'createdAt': + cmp = a.createdAt.localeCompare(b.createdAt); + break; + default: + cmp = a.order - b.order; + } + return order === 'desc' ? -cmp : cmp; + }); +} + +// ─── Priority Helpers ────────────────────────────────────── + +const PRIORITY_LABELS: Record = { + urgent: 'Dringend', + high: 'Hoch', + medium: 'Mittel', + low: 'Niedrig', +}; + +const PRIORITY_COLORS: Record = { + urgent: '#ef4444', + high: '#f59e0b', + medium: '#3b82f6', + low: '#6b7280', +}; + +export function getPriorityLabel(priority: TaskPriority): string { + return PRIORITY_LABELS[priority]; +} + +export function getPriorityColor(priority: TaskPriority): string { + return PRIORITY_COLORS[priority]; +} + +// ─── Stats ────────────────────────────────────────────────── + +export function getTaskStats(tasks: Task[]) { + const total = tasks.length; + const completed = tasks.filter((t) => t.isCompleted).length; + const overdue = filterOverdue(tasks).length; + const today = filterToday(tasks).length; + const upcoming = filterUpcoming(tasks).length; + + return { total, completed, overdue, today, upcoming }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/todo/stores/board-views.svelte.ts b/apps/manacore/apps/web/src/lib/modules/todo/stores/board-views.svelte.ts new file mode 100644 index 000000000..80d61aca1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/stores/board-views.svelte.ts @@ -0,0 +1,60 @@ +/** + * Board Views Store — Mutation-Only Service + * + * Reads via liveQuery (useAllBoardViews in queries.ts). + * This store only handles create, update, delete, reorder. + */ + +import { boardViewTable } from '../collections'; +import type { LocalBoardView, ViewColumn } from '../types'; + +export const boardViewsStore = { + async createView(data: Omit) { + const existing = await boardViewTable.toArray(); + const count = existing.filter((v) => !v.deletedAt).length; + + const newView: LocalBoardView = { + ...data, + id: crypto.randomUUID(), + order: data.order ?? count, + }; + await boardViewTable.add(newView); + return newView; + }, + + async updateView(id: string, data: Partial) { + await boardViewTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteView(id: string) { + await boardViewTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async reorderViews(viewIds: string[]) { + for (let i = 0; i < viewIds.length; i++) { + await boardViewTable.update(viewIds[i], { + order: i, + updatedAt: new Date().toISOString(), + }); + } + }, + + async updateColumnTaskIds(viewId: string, columnId: string, taskIds: string[]) { + const view = await boardViewTable.get(viewId); + if (!view) return; + + const updatedColumns = view.columns.map((col: ViewColumn) => + col.id === columnId ? { ...col, match: { ...col.match, taskIds } } : col + ); + await boardViewTable.update(viewId, { + columns: updatedColumns, + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/stores/labels.svelte.ts b/apps/manacore/apps/web/src/lib/modules/todo/stores/labels.svelte.ts new file mode 100644 index 000000000..0c3c39c84 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/stores/labels.svelte.ts @@ -0,0 +1,32 @@ +/** + * Labels Store — Mutation-Only Service + */ + +import { labelTable } from '../collections'; +import type { LocalLabel } from '../types'; + +export const labelsStore = { + async createLabel(data: { name: string; color: string }) { + const newLabel: LocalLabel = { + id: crypto.randomUUID(), + name: data.name, + color: data.color, + }; + await labelTable.add(newLabel); + return newLabel; + }, + + async updateLabel(id: string, data: Partial>) { + await labelTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteLabel(id: string) { + await labelTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts b/apps/manacore/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts new file mode 100644 index 000000000..9c3b36f5b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts @@ -0,0 +1,121 @@ +/** + * Tasks Store — Mutation-Only Service + * + * All reads are handled by liveQuery hooks in queries.ts. + * This store only provides write operations. + */ + +import { taskTable } from '../collections'; +import { toTask } from '../queries'; +import type { LocalTask, TaskPriority, Subtask } from '../types'; + +export const tasksStore = { + async createTask(data: { + title: string; + description?: string; + dueDate?: string; + priority?: TaskPriority; + projectId?: string | null; + subtasks?: Subtask[]; + recurrenceRule?: string; + estimatedDuration?: number; + }) { + const existing = await taskTable.toArray(); + const count = existing.filter((t) => !t.deletedAt).length; + + const newLocal: LocalTask = { + id: crypto.randomUUID(), + title: data.title, + description: data.description, + priority: data.priority ?? 'medium', + isCompleted: false, + dueDate: data.dueDate ?? null, + estimatedDuration: data.estimatedDuration ?? null, + order: count, + recurrenceRule: data.recurrenceRule ?? null, + subtasks: data.subtasks, + }; + + // Set projectId if provided + if (data.projectId !== undefined) { + (newLocal as Record).projectId = data.projectId; + } + + await taskTable.add(newLocal); + return toTask(newLocal); + }, + + async updateTask( + id: string, + data: Partial< + Pick< + LocalTask, + | 'title' + | 'description' + | 'dueDate' + | 'priority' + | 'isCompleted' + | 'order' + | 'subtasks' + | 'recurrenceRule' + | 'estimatedDuration' + | 'metadata' + > + > + ) { + await taskTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteTask(id: string) { + await taskTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async completeTask(id: string) { + await taskTable.update(id, { + isCompleted: true, + completedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async uncompleteTask(id: string) { + await taskTable.update(id, { + isCompleted: false, + completedAt: null, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleComplete(id: string) { + const task = await taskTable.get(id); + if (!task) return; + + if (task.isCompleted) { + await this.uncompleteTask(id); + } else { + await this.completeTask(id); + } + }, + + async updateSubtasks(id: string, subtasks: Subtask[]) { + await taskTable.update(id, { + subtasks, + updatedAt: new Date().toISOString(), + }); + }, + + async reorderTasks(taskIds: string[]) { + for (let i = 0; i < taskIds.length; i++) { + await taskTable.update(taskIds[i], { + order: i, + updatedAt: new Date().toISOString(), + }); + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/stores/view.svelte.ts b/apps/manacore/apps/web/src/lib/modules/todo/stores/view.svelte.ts new file mode 100644 index 000000000..284f84a61 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/stores/view.svelte.ts @@ -0,0 +1,101 @@ +/** + * View Store — Manages current view state using Svelte 5 runes. + */ + +import type { ViewType, SortBy, SortOrder } from '../types'; + +let currentView = $state('inbox'); +let currentLabelId = $state(null); +let currentProjectId = $state(null); +let searchQuery = $state(''); +let sortBy = $state('order'); +let sortOrder = $state('asc'); +let showCompleted = $state(false); + +export const viewStore = { + get currentView() { + return currentView; + }, + get currentLabelId() { + return currentLabelId; + }, + get currentProjectId() { + return currentProjectId; + }, + get searchQuery() { + return searchQuery; + }, + get sortBy() { + return sortBy; + }, + get sortOrder() { + return sortOrder; + }, + get showCompleted() { + return showCompleted; + }, + + setInbox() { + currentView = 'inbox'; + currentLabelId = null; + currentProjectId = null; + searchQuery = ''; + }, + + setToday() { + currentView = 'today'; + currentLabelId = null; + searchQuery = ''; + }, + + setUpcoming() { + currentView = 'upcoming'; + currentLabelId = null; + searchQuery = ''; + }, + + setLabel(labelId: string) { + currentView = 'label'; + currentLabelId = labelId; + searchQuery = ''; + }, + + setCompleted() { + currentView = 'completed'; + currentLabelId = null; + searchQuery = ''; + }, + + setSearch(query: string) { + currentView = 'search'; + currentLabelId = null; + searchQuery = query; + }, + + updateSearchQuery(query: string) { + searchQuery = query; + }, + + setSort(by: SortBy, order: SortOrder = 'asc') { + sortBy = by; + sortOrder = order; + }, + + toggleSortOrder() { + sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + }, + + toggleShowCompleted() { + showCompleted = !showCompleted; + }, + + reset() { + currentView = 'inbox'; + currentLabelId = null; + currentProjectId = null; + searchQuery = ''; + sortBy = 'order'; + sortOrder = 'asc'; + showCompleted = false; + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/types.ts b/apps/manacore/apps/web/src/lib/modules/todo/types.ts new file mode 100644 index 000000000..ae02a2399 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/types.ts @@ -0,0 +1,128 @@ +/** + * Todo module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +// ─── Local Types (IndexedDB) ────────────────────────────── + +export interface Subtask { + id: string; + title: string; + isCompleted: boolean; + completedAt?: string | null; + order: number; +} + +export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface LocalTask extends BaseRecord { + title: string; + description?: string; + userId?: string; + projectId?: string | null; + priority: TaskPriority; + isCompleted: boolean; + completedAt?: string | null; + dueDate?: string | null; + scheduledDate?: string | null; + scheduledStartTime?: string | null; + estimatedDuration?: number | null; + order: number; + recurrenceRule?: string | null; + subtasks?: Subtask[] | null; + metadata?: Record; +} + +export interface LocalLabel extends BaseRecord { + name: string; + color: string; + userId?: string; +} + +export interface LocalTaskLabel extends BaseRecord { + taskId: string; + labelId: string; +} + +export interface LocalReminder extends BaseRecord { + taskId: string; + userId?: string; + minutesBefore: number; + type: 'push' | 'email' | 'both'; + status: 'pending' | 'sent' | 'failed'; +} + +// ─── Board Views ──────────────────────────────────────────── + +export interface TaskMatcher { + type: 'status' | 'priority' | 'tag' | 'dueDate' | 'custom'; + value?: string | null; + taskIds?: string[]; +} + +export interface DropAction { + setCompleted?: boolean; + setPriority?: TaskPriority; +} + +export interface ViewColumn { + id: string; + name: string; + color: string; + match: TaskMatcher; + onDrop?: DropAction; +} + +export interface ViewFilter { + tagIds?: string[]; + priorities?: string[]; +} + +export interface LocalBoardView extends BaseRecord { + name: string; + icon: string; + groupBy: 'status' | 'priority' | 'dueDate' | 'tag' | 'custom'; + columns: ViewColumn[]; + filter?: ViewFilter; + layout: 'kanban' | 'grid' | 'fokus'; + order: number; +} + +export interface LocalTodoProject extends BaseRecord { + name: string; + color?: string | null; + icon?: string | null; + order: number; + isArchived?: boolean; + isDefault?: boolean; +} + +// ─── Shared Task Type ────────────────────────────────────── + +export interface Task { + id: string; + projectId?: string | null; + userId: string; + title: string; + description?: string | null; + dueDate?: string | null; + scheduledDate?: string | null; + scheduledStartTime?: string | null; + estimatedDuration?: number | null; + priority: TaskPriority; + status: TaskStatus; + isCompleted: boolean; + completedAt?: string | null; + order: number; + recurrenceRule?: string | null; + subtasks?: Subtask[] | null; + metadata?: Record | null; + createdAt: string; + updatedAt: string; +} + +export type ViewType = 'inbox' | 'today' | 'upcoming' | 'label' | 'completed' | 'search'; +export type SortBy = 'dueDate' | 'priority' | 'title' | 'createdAt' | 'order'; +export type SortOrder = 'asc' | 'desc'; diff --git a/apps/manacore/apps/web/src/routes/(app)/calendar/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/calendar/+layout.svelte new file mode 100644 index 000000000..9d0c5dfa0 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/calendar/+layout.svelte @@ -0,0 +1,21 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte new file mode 100644 index 000000000..c4f001eac --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte @@ -0,0 +1,499 @@ + + + + Kalender - ManaCore + + +
+ +
+
+

{headerLabel}

+
+ + + +
+
+ +
+ +
+ {#each ['week', 'month', 'agenda'] as view} + + {/each} +
+ + + +
+
+ + +
+ {#if calendarViewStore.viewType === 'week'} + +
+ +
+
+ {#each weekDays as day} + + {/each} +
+ + +
+ {#each hours as hour} +
+
+ {hour.toString().padStart(2, '0')}:00 +
+ {#each weekDays as day} + + {/each} +
+ {/each} +
+
+ {:else if calendarViewStore.viewType === 'month'} + +
+
+ + {#each ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as dayName} +
+ {dayName} +
+ {/each} + + + {#each eachDayOfInterval( { start: startOfWeek( calendarViewStore.viewRange.start, { weekStartsOn: 1 } ), end: endOfWeek( calendarViewStore.viewRange.end, { weekStartsOn: 1 } ) } ) as day} + + {/each} +
+
+ {:else} + +
+ {#if rangeEvents.length === 0} +
+

Keine Termine in den nächsten 30 Tagen

+ +
+ {:else} + {@const groupedByDate = rangeEvents.reduce( + (acc, event) => { + const key = format(new Date(event.startTime), 'yyyy-MM-dd'); + if (!acc[key]) acc[key] = []; + acc[key].push(event); + return acc; + }, + {} as Record + )} + + {#each Object.entries(groupedByDate) as [dateKey, dayEvents]} +
+

+ {format(new Date(dateKey), 'EEEE, d. MMMM', { locale: de })} + {#if isToday(new Date(dateKey))} + Heute + {/if} +

+
+ {#each dayEvents as event (event.id)} + + {/each} +
+
+ {/each} + {/if} +
+ {/if} +
+
+ + +{#if showEventForm} +
+
+

+ {editingEvent ? 'Termin bearbeiten' : 'Neuer Termin'} +

+ +
{ + e.preventDefault(); + handleSaveEvent(); + }} + class="space-y-4" + > +
+ + +
+ +
+ + +
+ + + + {#if !newAllDay} +
+
+ + +
+
+ + +
+
+ {/if} + +
+ + +
+ +
+ {#if editingEvent} + + {/if} +
+ + +
+
+
+
+{/if} + + diff --git a/apps/manacore/apps/web/src/routes/(app)/calendar/calendars/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/calendar/calendars/+page.svelte new file mode 100644 index 000000000..69f1aed37 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/calendar/calendars/+page.svelte @@ -0,0 +1,185 @@ + + + + Kalender verwalten - ManaCore + + +
+ + + Zurück zum Kalender + + +
+

Meine Kalender

+ +
+ + + {#if showCreateForm} +
+
{ + e.preventDefault(); + handleCreate(); + }} + class="space-y-3" + > +
+ + +
+ +
+ +
+ {#each PRESET_COLORS as color} + + {/each} +
+
+ +
+ + +
+
+
+ {/if} + + +
+ {#if calendarsCtx.value.length === 0} +
Noch keine Kalender vorhanden.
+ {:else} + {#each calendarsCtx.value as cal (cal.id)} +
+
+
+
+ {cal.name} + {#if cal.isDefault} + (Standard) + {/if} +
+
+
+ + {#if !cal.isDefault} + + {/if} + +
+
+ {/each} + {/if} +
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/calendar/event/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/calendar/event/[id]/+page.svelte new file mode 100644 index 000000000..52ec84480 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/calendar/event/[id]/+page.svelte @@ -0,0 +1,269 @@ + + + + {event?.title ?? 'Termin'} - Kalender - ManaCore + + +
+ + + + {#if !event} +
+

Termin nicht gefunden

+ +
+ {:else if isEditing} + +
+

Termin bearbeiten

+ +
{ + e.preventDefault(); + handleSave(); + }} + class="space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + {#if !editAllDay} +
+
+ + +
+
+ + +
+
+ {/if} + +
+ + +
+ +
+ + +
+
+
+ {:else} + +
+
+
+
+
+

{event.title}

+ {#if calendar} +

{calendar.name}

+ {/if} +
+
+ +
+ + +
+
+ +
+ +
+ +
+
{format(new Date(event.startTime), 'EEEE, d. MMMM yyyy', { locale: de })}
+ {#if event.isAllDay} +
Ganztägig
+ {:else} +
+ {format(new Date(event.startTime), 'HH:mm')} – {format( + new Date(event.endTime), + 'HH:mm' + )} Uhr +
+ {/if} +
+
+ + + {#if event.location} +
+ + {event.location} +
+ {/if} + + + {#if event.description} +
+

{event.description}

+
+ {/if} +
+
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/chat/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/chat/+layout.svelte new file mode 100644 index 000000000..e06eece3b --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/chat/+layout.svelte @@ -0,0 +1,23 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/chat/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/chat/+page.svelte new file mode 100644 index 000000000..ac68eb7da --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/chat/+page.svelte @@ -0,0 +1,242 @@ + + + + Chat - ManaCore + + +
+ +
+
+

Chat

+

+ {conversationsCtx.value.length} Konversationen +

+
+
+ + Vorlagen + + +
+
+ + +
+ + +
+ + + {#if conversationsCtx.value.length === 0} +
+
+ +
+

+ Starte deine erste Unterhaltung +

+

+ Stelle eine Frage oder bitte um Hilfe bei einem Projekt. +

+ +
+ {:else} +
+ + {#if pinned.length > 0} +
+

+ Angepinnt +

+
+ {#each pinned as conv (conv.id)} +
handleConversationClick(conv.id)} + onkeydown={(e) => e.key === 'Enter' && handleConversationClick(conv.id)} + class="group flex items-center gap-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-3 transition-all hover:border-[hsl(var(--primary)/0.3)]" + > + +
+

+ {conv.title || 'Neue Konversation'} +

+

+ {formatDate(conv.updatedAt)} +

+
+
+ + +
+
+ {/each} +
+
+ {/if} + + + {#if unpinned.length > 0} +
+ {#if pinned.length > 0} +

+ Zuletzt +

+ {/if} +
+ {#each unpinned as conv (conv.id)} +
handleConversationClick(conv.id)} + onkeydown={(e) => e.key === 'Enter' && handleConversationClick(conv.id)} + class="group flex items-center gap-3 rounded-lg border border-transparent p-3 transition-all hover:border-[hsl(var(--border))] hover:bg-[hsl(var(--card))]" + > + +
+

+ {conv.title || 'Neue Konversation'} +

+

+ {formatDate(conv.updatedAt)} +

+
+
+ + +
+
+ {/each} +
+
+ {/if} +
+ {/if} + + + +
diff --git a/apps/manacore/apps/web/src/routes/(app)/chat/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/chat/[id]/+page.svelte new file mode 100644 index 000000000..3f84c607f --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/chat/[id]/+page.svelte @@ -0,0 +1,216 @@ + + + + {conversation?.title || 'Chat'} - ManaCore + + +
+ +
+ + + + +
+ {#if isEditingTitle} +
+ e.key === 'Enter' && saveTitle()} + class="flex-1 rounded border border-[hsl(var(--border))] bg-transparent px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-[hsl(var(--primary))]" + /> + + +
+ {:else} + + {/if} +
+ +
+ + +
+
+ + +
+ {#if messages.length === 0} +
+

+ Schreibe eine Nachricht, um die Unterhaltung zu starten. +

+
+ {:else} +
+ {#each messages as msg (msg.id)} +
+
+

{msg.messageText}

+

+ {new Date(msg.createdAt).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + })} +

+
+
+ {/each} +
+ {/if} +
+ + +
+
+ + +
+
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/chat/archive/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/chat/archive/+page.svelte new file mode 100644 index 000000000..305bef102 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/chat/archive/+page.svelte @@ -0,0 +1,100 @@ + + + + Archiv - Chat - ManaCore + + +
+ +
+ + + +
+

Archiv

+

+ {archivedCtx.value.length} archivierte Konversationen +

+
+
+ + {#if archivedCtx.value.length === 0} +
+ +

Keine archivierten Konversationen

+
+ {:else} +
+ {#each archivedCtx.value as conv (conv.id)} +
handleClick(conv.id)} + onkeydown={(e) => e.key === 'Enter' && handleClick(conv.id)} + class="group flex items-center gap-3 rounded-lg border border-transparent p-3 transition-all hover:border-[hsl(var(--border))] hover:bg-[hsl(var(--card))]" + > + +
+

+ {conv.title || 'Konversation ohne Titel'} +

+

+ {formatDate(conv.updatedAt)} +

+
+
+ + +
+
+ {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/chat/templates/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/chat/templates/+page.svelte new file mode 100644 index 000000000..7870ba3e7 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/chat/templates/+page.svelte @@ -0,0 +1,310 @@ + + + + Vorlagen - Chat - ManaCore + + +
+ +
+
+ + + +
+

Vorlagen

+

+ Erstelle Vorlagen mit benutzerdefinierten System-Prompts. +

+
+
+ +
+ + + {#if templates.length === 0} +
+ +

Keine Vorlagen

+

+ Erstelle deine erste Vorlage, um loszulegen. +

+ +
+ {:else} +
+ {#each templates as template (template.id)} +
+
+
+
+
+

+ {template.name} + {#if template.isDefault} + + {/if} +

+ {#if template.description} +

+ {template.description} +

+ {/if} +
+
+
+ + {#if template.documentMode} + + Dokumentmodus + + {/if} + +
+ + + + +
+
+ {/each} +
+ {/if} +
+ + +{#if showForm} +
+
+
+

+ {editingId ? 'Vorlage bearbeiten' : 'Neue Vorlage'} +

+ +
+ +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+
+ +
+ {#each COLORS as color} + + {/each} +
+
+ +
+ + +
+
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/contacts/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/contacts/+layout.svelte new file mode 100644 index 000000000..6b55268a3 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/contacts/+layout.svelte @@ -0,0 +1,19 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/contacts/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/contacts/+page.svelte new file mode 100644 index 000000000..6ac4d721e --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/contacts/+page.svelte @@ -0,0 +1,344 @@ + + + + Kontakte - ManaCore + + +
+ +
+
+

Kontakte

+

+ {totalCount} Kontakte{favoriteCount > 0 ? ` · ${favoriteCount} Favoriten` : ''} +

+
+ +
+ + +
+
+ + contactsFilterStore.setSearchQuery(e.currentTarget.value)} + class="w-full rounded-lg border border-border bg-card py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ +
+ + + {#if showFilters} +
+ {#each [{ value: 'all', label: 'Alle' }, { value: 'favorites', label: 'Favoriten' }, { value: 'hasEmail', label: 'Mit E-Mail' }, { value: 'hasPhone', label: 'Mit Telefon' }, { value: 'incomplete', label: 'Unvollstaendig' }] as filter} + + {/each} +
+ {/if} + + + {#if sorted.length === 0} +
+
+ +
+ {#if contactsFilterStore.searchQuery} +

Keine Ergebnisse

+

+ Keine Kontakte gefunden fuer "{contactsFilterStore.searchQuery}" +

+ {:else} +

Noch keine Kontakte

+

+ Erstelle deinen ersten Kontakt oder importiere bestehende. +

+
+ + + Importieren + +
+ {/if} +
+ {:else} + + {#each letters as letter (letter)} + + {/each} + +

+ {sorted.length} Kontakt{sorted.length !== 1 ? 'e' : ''} +

+ {/if} +
+ + +{#if contactModalStore.isOpen} + {@const isEditing = !!contactModalStore.editContactId} + +{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/contacts/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/contacts/[id]/+page.svelte new file mode 100644 index 000000000..69e167698 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/contacts/[id]/+page.svelte @@ -0,0 +1,371 @@ + + + + {contact ? getDisplayName(contact) : 'Kontakt'} - ManaCore + + +
+ + + + Kontakte + + + {#if !contact} +
+

Kontakt nicht gefunden

+

+ Dieser Kontakt existiert nicht oder wurde geloescht. +

+ + Zurueck zu Kontakten + +
+ {:else} + +
+
+ +
+ {#if contact.photoUrl} + {getDisplayName(contact)} + {:else} + {getInitials(contact)} + {/if} +
+ + +
+
+

{getDisplayName(contact)}

+ {#if contact.isFavorite} + + {/if} +
+ {#if contact.company || contact.jobTitle} +

+ {[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')} +

+ {/if} +
+ + +
+ + + + +
+
+
+ + + {#if isEditing} + +
+

+ Bearbeiten +

+
+
+ (editData.firstName = e.currentTarget.value || null)} + class="rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + (editData.lastName = e.currentTarget.value || null)} + class="rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ (editData.email = e.currentTarget.value || null)} + class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + (editData.phone = e.currentTarget.value || null)} + class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + (editData.company = e.currentTarget.value || null)} + class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + (editData.jobTitle = e.currentTarget.value || null)} + class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + (editData.birthday = e.currentTarget.value || null)} + class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + + +
+ + +
+
+
+ {:else} + +
+ +
+

+ Kontaktdaten +

+
+ {#if contact.email} + + {/if} + {#if contact.phone} + + {/if} + {#if contact.company} +
+ + {contact.company} +
+ {/if} + {#if contact.birthday} +
+ + + {new Date(contact.birthday).toLocaleDateString('de-DE', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+ {/if} +
+ + {#if !contact.email && !contact.phone && !contact.company && !contact.birthday} +

Keine Kontaktdaten hinterlegt.

+ {/if} +
+ + + {#if contact.notes} +
+

+ Notizen +

+

{contact.notes}

+
+ {/if} + + + {#if contact.tags.length > 0} +
+

+ Tags +

+
+ {#each contact.tags as tag (tag.id)} + + {tag.name} + + {/each} +
+
+ {/if} + + +
+

+ Details +

+
+ Erstellt + + {new Date(contact.createdAt).toLocaleDateString('de-DE', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + Aktualisiert + + {new Date(contact.updatedAt).toLocaleDateString('de-DE', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + +
+
+
+ {/if} + {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/memoro/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/memoro/+layout.svelte new file mode 100644 index 000000000..a72551e3b --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/memoro/+layout.svelte @@ -0,0 +1,26 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/memoro/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/memoro/+page.svelte new file mode 100644 index 000000000..b0c5b0131 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/memoro/+page.svelte @@ -0,0 +1,262 @@ + + + + Memoro - ManaCore + + +
+ +
+
+

Memoro

+

+ {memosCtx.value.length} Memos +

+
+
+ + + Tags + + +
+
+ + +
+ + +
+ + + {#if tagsCtx.value.length > 0} +
+ + {#each tagsCtx.value as tag (tag.id)} + + {/each} +
+ {/if} + + + {#if memosCtx.value.length === 0} +
+
+ +
+

+ Erstelle dein erstes Memo +

+

+ Nimm Gedanken auf oder schreibe sie direkt auf. +

+ +
+ {:else} +
+ {#each filtered() as memo (memo.id)} +
handleMemoClick(memo.id)} + onkeydown={(e) => e.key === 'Enter' && handleMemoClick(memo.id)} + class="group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]" + > +
+
+
+ {#if memo.isPinned} + + {/if} +

+ {memo.title || 'Unbenanntes Memo'} +

+
+ {#if memo.intro} +

+ {memo.intro} +

+ {:else if memo.transcript} +

+ {memo.transcript} +

+ {/if} +
+
+ + +
+
+ + +
+ + {formatDate(memo.createdAt)} + + {#if memo.audioDurationMs} + + {formatDuration(memo.audioDurationMs)} + + {/if} + {#if memo.processingStatus !== 'completed'} + + {getStatusLabel(memo.processingStatus)} + + {/if} + + {#each getMemoTags(memo.id) as tag (tag.id)} + + {tag.name} + + {/each} +
+
+ {/each} +
+ {/if} + + + +
diff --git a/apps/manacore/apps/web/src/routes/(app)/memoro/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/memoro/[id]/+page.svelte new file mode 100644 index 000000000..a6e0f758a --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/memoro/[id]/+page.svelte @@ -0,0 +1,289 @@ + + + + {memo?.title || 'Memo'} - Memoro - ManaCore + + +{#if !memo} +
+

Memo nicht gefunden

+ + Zuruck zu Memoro + +
+{:else} +
+ +
+
+ + + +
+ {#if isEditingTitle} +
+ e.key === 'Enter' && saveTitle()} + class="flex-1 rounded border border-[hsl(var(--border))] bg-transparent px-2 py-1 text-xl font-bold focus:outline-none focus:ring-1 focus:ring-[hsl(var(--primary))]" + /> + + +
+ {:else} + + {/if} +

+ {new Date(memo.createdAt).toLocaleDateString('de-DE', { + day: '2-digit', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + {#if memo.audioDurationMs} + · {formatDuration(memo.audioDurationMs)} + {/if} +

+
+
+ +
+ + + +
+
+ + + {#if memo.processingStatus !== 'completed'} +
+ Status: {getStatusLabel(memo.processingStatus)} +
+ {/if} + + +
+ {#each memoTags as tag (tag.id)} + + {tag.name} + + + {/each} +
+ + {#if showTagPicker && availableTags.length > 0} +
+ {#each availableTags as tag (tag.id)} + + {/each} +
+ {/if} +
+
+ + + {#if memo.intro} +
+

+ Zusammenfassung +

+

{memo.intro}

+
+ {/if} + + + {#if memo.transcript} +
+

+ Transkript +

+

+ {memo.transcript} +

+
+ {/if} + + +
+

+ Erinnerungen ({memories.length}) +

+ {#if memories.length === 0} +

+ Noch keine Erinnerungen fur dieses Memo. +

+ {:else} +
+ {#each memories as memory (memory.id)} +
+

{memory.title}

+ {#if memory.content} +

+ {memory.content} +

+ {/if} +
+ {/each} +
+ {/if} +
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/memoro/archive/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/memoro/archive/+page.svelte new file mode 100644 index 000000000..80e41d497 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/memoro/archive/+page.svelte @@ -0,0 +1,108 @@ + + + + Archiv - Memoro - ManaCore + + +
+ +
+ + + +
+

Archiv

+

+ {archivedCtx.value.length} archivierte Memos +

+
+
+ + {#if archivedCtx.value.length === 0} +
+ +

Keine archivierten Memos

+
+ {:else} +
+ {#each archivedCtx.value as memo (memo.id)} +
handleClick(memo.id)} + onkeydown={(e) => e.key === 'Enter' && handleClick(memo.id)} + class="group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]" + > +
+
+

+ {memo.title || 'Unbenanntes Memo'} +

+ {#if memo.intro} +

+ {memo.intro} +

+ {/if} +

+ {formatDate(memo.updatedAt)} +

+
+
+ + +
+
+
+ {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/memoro/tags/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/memoro/tags/+page.svelte new file mode 100644 index 000000000..8ac126a0e --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/memoro/tags/+page.svelte @@ -0,0 +1,224 @@ + + + + Tags - Memoro - ManaCore + + +
+ +
+
+ + + +
+

Tags

+

+ {tagsCtx.value.length} Tags +

+
+
+ +
+ + {#if tagsCtx.value.length === 0} +
+ +

Keine Tags

+

+ Erstelle Tags, um deine Memos zu organisieren. +

+ +
+ {:else} +
+ {#each tagsCtx.value as tag (tag.id)} +
+ + {tag.name} + {#if tag.isPinned} + + {/if} +
+ + + +
+
+ {/each} +
+ {/if} +
+ + +{#if showCreateForm} +
+
+
+

+ {editingId ? 'Tag bearbeiten' : 'Neuer Tag'} +

+ +
+
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-4" + > +
+ + +
+
+ +
+ {#each COLORS as color} + + {/each} +
+
+
+ + +
+
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/mukke/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/mukke/+layout.svelte new file mode 100644 index 000000000..84471b2af --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/mukke/+layout.svelte @@ -0,0 +1,26 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/mukke/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/mukke/+page.svelte new file mode 100644 index 000000000..2c08c627b --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/mukke/+page.svelte @@ -0,0 +1,125 @@ + + + + Mukke - ManaCore + + +
+

Mukke

+ + +
+

+ Bibliothek +

+
+
+

Songs

+

{stats.totalSongs}

+
+
+

Alben

+

{stats.totalAlbums}

+
+
+

Kunstler

+

{stats.totalArtists}

+
+
+

Genres

+

{stats.totalGenres}

+
+
+
+ + +
+

+ Schnellzugriff +

+ +
+ + +
+
+

+ Letzte Projekte +

+ + Alle anzeigen + +
+ {#if projectsCtx.value.length === 0} +
+ +

Noch keine Projekte

+
+ {:else} +
+ {#each projectsCtx.value.slice(0, 6) as project (project.id)} +
+

{project.title}

+ {#if project.description} +

+ {project.description} +

+ {/if} +

+ Aktualisiert {formatDate(project.updatedAt)} +

+
+ {/each} +
+ {/if} +
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/mukke/library/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/mukke/library/+page.svelte new file mode 100644 index 000000000..9021a2cdd --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/mukke/library/+page.svelte @@ -0,0 +1,242 @@ + + + + Bibliothek - Mukke - ManaCore + + +
+ +
+ + + +

Bibliothek

+
+ + +
+ {#each tabs as tab} + + {/each} +
+ + + {#if activeTab === 'songs'} +
+ + +
+ {/if} + + + {#if activeTab === 'songs'} + {#if filteredSongs.length === 0} +
+ +

+ {searchQuery ? 'Keine Songs gefunden' : 'Noch keine Songs in deiner Bibliothek'} +

+
+ {:else} +
+ +
+ + Titel + Kunstler + Dauer + + +
+ + {#each filteredSongs as song, index (song.id)} +
handlePlaySong(song, index)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handlePlaySong(song, index); + } + }} + class="group grid grid-cols-[40px_1fr_1fr_80px_40px_40px] items-center gap-4 px-4 py-3 transition-colors hover:bg-[hsl(var(--muted))] {playerStore + .currentSong?.id === song.id + ? 'bg-[hsl(var(--primary)/0.05)]' + : ''}" + > +
+ + {#if playerStore.currentSong?.id === song.id && playerStore.isPlaying} +
+ +
+ {:else} + + {/if} +
+ + {song.title} + + + {song.artist ?? 'Unbekannt'} + + + {formatDuration(song.duration)} + + + +
+ {/each} +
+ {/if} + {/if} + + + {#if activeTab === 'albums'} + {#if albums.length === 0} +
+

Keine Alben gefunden

+
+ {:else} +
+ {#each albums as album} +
+
+ +
+

{album.album}

+

+ {album.songCount} + {album.songCount === 1 ? 'Song' : 'Songs'} +

+
+ {/each} +
+ {/if} + {/if} + + + {#if activeTab === 'genres'} + {#if genres.length === 0} +
+

Keine Genres gefunden

+
+ {:else} +
+ {#each genres as genre} +
+ {genre.genre} + + {genre.songCount} + {genre.songCount === 1 ? 'Song' : 'Songs'} + +
+ {/each} +
+ {/if} + {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/mukke/playlists/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/mukke/playlists/+page.svelte new file mode 100644 index 000000000..1b3eb2fd8 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/mukke/playlists/+page.svelte @@ -0,0 +1,177 @@ + + + + Playlists - Mukke - ManaCore + + +
+ +
+
+ + + +

Playlists

+
+ +
+ + {#if playlistsCtx.value.length === 0} +
+ +

Noch keine Playlists

+ +
+ {:else} + + {/if} +
+ + +{#if showCreateModal} +
+
+
+

Neue Playlist

+ +
+
{ + e.preventDefault(); + handleCreate(); + }} + > +
+ + +
+
+ + +
+
+ + +
+
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/mukke/playlists/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/mukke/playlists/[id]/+page.svelte new file mode 100644 index 000000000..5d5e164d3 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/mukke/playlists/[id]/+page.svelte @@ -0,0 +1,204 @@ + + + + {playlist?.name || 'Playlist'} - Mukke - ManaCore + + +
+ +
+
+ + + +
+ {#if isEditingName} +
+ e.key === 'Enter' && saveName()} + class="rounded border border-[hsl(var(--border))] bg-transparent px-2 py-1 text-xl font-bold focus:outline-none focus:ring-1 focus:ring-[hsl(var(--primary))]" + /> + + +
+ {:else} + + {/if} +

+ {songs.length} + {songs.length === 1 ? 'Song' : 'Songs'} +

+
+
+
+ {#if songs.length > 0} + + {/if} + +
+
+ + + {#if songs.length === 0} +
+ +

Keine Songs in dieser Playlist

+
+ {:else} +
+ {#each songs as song, index (song.id)} +
handlePlaySong(song, index)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handlePlaySong(song, index); + } + }} + class="group flex items-center gap-4 border-b border-[hsl(var(--border))] px-4 py-3 transition-colors last:border-b-0 hover:bg-[hsl(var(--muted))] {playerStore + .currentSong?.id === song.id + ? 'bg-[hsl(var(--primary)/0.05)]' + : ''}" + > +
+ + {#if playerStore.currentSong?.id === song.id && playerStore.isPlaying} +
+ +
+ {:else} + + {/if} +
+
+

+ {song.title} +

+

+ {song.artist ?? 'Unbekannt'} +

+
+ + {formatDuration(song.duration)} + + +
+ {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/mukke/projects/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/mukke/projects/+page.svelte new file mode 100644 index 000000000..8561172f2 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/mukke/projects/+page.svelte @@ -0,0 +1,177 @@ + + + + Projekte - Mukke - ManaCore + + +
+ +
+
+ + + +

Projekte

+
+ +
+ + {#if projectsCtx.value.length === 0} +
+ +

Noch keine Projekte

+ +
+ {:else} +
+ {#each projectsCtx.value as project (project.id)} +
+
+

{project.title}

+ +
+ {#if project.description} +

+ {project.description} +

+ {/if} +

+ Aktualisiert {formatDate(project.updatedAt)} +

+
+ {/each} +
+ {/if} +
+ + +{#if showCreateModal} +
+
+
+

Neues Projekt

+ +
+
{ + e.preventDefault(); + handleCreate(); + }} + > +
+ + +
+
+ + +
+
+ + +
+
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/picture/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/picture/+layout.svelte new file mode 100644 index 000000000..2ab3ff391 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/picture/+layout.svelte @@ -0,0 +1,33 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte new file mode 100644 index 000000000..b69edcdd1 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte @@ -0,0 +1,300 @@ + + + + Galerie - Picture - ManaCore + + +
+ +
+
+

Galerie

+
+ +
+ + + +
+ + + + Generieren + +
+
+ + +
+
+ + +
+ + + + {#each allPictureTags.value as tag (tag.id)} + + {/each} +
+
+ + +
+ {#if filteredImages.length === 0} +
+ +

+ {allImages.value.length === 0 ? 'Noch keine Bilder' : 'Keine Ergebnisse'} +

+

+ {allImages.value.length === 0 + ? 'Generiere dein erstes Bild mit KI' + : 'Passe deine Filter an'} +

+ {#if allImages.value.length === 0} + + Erstes Bild generieren + + {/if} +
+ {:else} +
+ {#each filteredImages as img (img.id)} + + {/each} +
+ {/if} +
+
+ + +{#if selectedImage} +
+
+ +
+ {#if selectedImage.publicUrl} + {selectedImage.prompt} + {:else} +
+ +
+ {/if} +
+ + +
+

{selectedImage.prompt}

+ {#if selectedImage.model} +

Modell: {selectedImage.model}

+ {/if} + {#if selectedImage.width && selectedImage.height} +

+ {selectedImage.width} x {selectedImage.height} +

+ {/if} +

+ {new Date(selectedImage.createdAt).toLocaleDateString('de-DE', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +

+ +
+ + +
+ +
+
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/picture/archive/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/picture/archive/+page.svelte new file mode 100644 index 000000000..6fb237629 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/picture/archive/+page.svelte @@ -0,0 +1,90 @@ + + + + Archiv - Picture - ManaCore + + +
+
+

Archiv

+

+ Archivierte Bilder werden nicht in der Galerie angezeigt +

+
+ + {#if archivedImages.value.length === 0} +
+ +

Archiv ist leer

+

Archivierte Bilder erscheinen hier

+
+ {:else} +
+ {#each archivedImages.value as img (img.id)} +
+ {#if img.publicUrl} + {img.prompt} + {:else} +
+ +
+ {/if} + + +
+ + +
+ +
+

{img.prompt}

+
+
+ {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/picture/board/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/picture/board/+page.svelte new file mode 100644 index 000000000..16d4b28ab --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/picture/board/+page.svelte @@ -0,0 +1,240 @@ + + + + Moodboards - Picture - ManaCore + + +
+
+
+

Moodboards

+

+ Erstelle und organisiere deine Bilder auf einem Canvas +

+
+ +
+ + {#if allBoards.value.length === 0} + +
+ +

Keine Boards vorhanden

+

+ Erstelle dein erstes Moodboard und organisiere deine Bilder +

+ +
+ {:else} + +
+ {#each allBoards.value as board (board.id)} +
+ + + + +
+ + +
+ {board.itemCount} {board.itemCount === 1 ? 'Element' : 'Elemente'} + {new Date(board.updatedAt).toLocaleDateString('de-DE')} +
+ + +
+ + +
+
+
+ {/each} +
+ {/if} +
+ + +{#if showCreateForm} +
+
+

Neues Board erstellen

+ +
{ + e.preventDefault(); + handleCreateBoard(); + }} + class="space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+{/if} + + +{#if showDeleteConfirm} +
+
+

Board löschen?

+

+ Möchtest du dieses Board wirklich löschen? Alle Bilder auf dem Board bleiben in deiner + Galerie erhalten. +

+
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/picture/board/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/picture/board/[id]/+page.svelte new file mode 100644 index 000000000..a4bf08f5e --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/picture/board/[id]/+page.svelte @@ -0,0 +1,166 @@ + + + + {board?.name ?? 'Board'} - Picture - ManaCore + + +
+ {#if !board} +
+

Board nicht gefunden

+ + Zurück zu den Boards + +
+ {:else} + +
+
+ + + +
+

{board.name}

+ {#if board.description} +

{board.description}

+ {/if} +
+
+ +
+ + +
+
+ + +
+
+ +

Canvas-Editor

+

+ Der vollständige Canvas-Editor mit Drag-and-Drop wird in einem zukünftigen Update + hinzugefügt. +

+

+ {board.canvasWidth} x {board.canvasHeight} px · {board.itemCount} + {board.itemCount === 1 ? 'Element' : 'Elemente'} +

+
+
+ {/if} +
+ + +{#if isEditing && board} +
+
+

Board bearbeiten

+ +
{ + e.preventDefault(); + handleSave(); + }} + class="space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/picture/generate/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/picture/generate/+page.svelte new file mode 100644 index 000000000..ec81f036b --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/picture/generate/+page.svelte @@ -0,0 +1,153 @@ + + + + Generieren - Picture - ManaCore + + +
+
+

Bild generieren

+

+ Erstelle beeindruckende KI-Bilder aus deinen Textbeschreibungen +

+
+ + +
{ + e.preventDefault(); + handleGenerate(); + }} + class="space-y-4" + > + +
+ + +
+ + +
+ + +
+ + + + + {#if generationError} +
+ {generationError} +
+ {/if} +
+ + +
+

+ Prompt-Vorschläge +

+
+ {#each PROMPT_SUGGESTIONS as suggestion} + + {/each} +
+
+ + +
+

Tipps für bessere Ergebnisse

+
    +
  • + + Sei spezifisch: Beschreibe Stil, Stimmung, Farben + und Komposition +
  • +
  • + + Beschreibende Wörter: "Lebhafter Sonnenuntergang + über Bergen" ist besser als "Sonnenuntergang" +
  • +
  • + + Negativ-Prompts: Schließe unerwünschte Elemente aus + (z.B. "unscharf, verzerrt, niedrige Qualität") +
  • +
+
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/todo/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/todo/+layout.svelte new file mode 100644 index 000000000..755971478 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/todo/+layout.svelte @@ -0,0 +1,26 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/todo/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/todo/+page.svelte new file mode 100644 index 000000000..dd2f28453 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/todo/+page.svelte @@ -0,0 +1,471 @@ + + + + Todo - ManaCore + + +
+ +
+

Todo

+
+ {stats.total} Aufgaben + {stats.completed} erledigt + {#if stats.overdue > 0} + {stats.overdue} ueberfaellig + {/if} + {#if stats.today > 0} + {stats.today} heute + {/if} +
+
+ + +
+ {#each views as view} + + {/each} +
+ + + {#if viewStore.currentView === 'search'} +
+ + viewStore.updateSearchQuery(e.currentTarget.value)} + class="w-full rounded-lg border border-border bg-card py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ {/if} + + + {#if isAdding} +
+
{ + e.preventDefault(); + handleQuickAdd(); + }} + class="flex gap-2" + > + + + +
+
+ {:else} + + {/if} + + + {#if displayTasks.length === 0} +
+
+ +
+

+ {#if viewStore.currentView === 'completed'} + Noch keine Aufgaben erledigt + {:else if viewStore.currentView === 'today'} + Keine Aufgaben fuer heute + {:else if viewStore.currentView === 'upcoming'} + Keine anstehenden Aufgaben + {:else} + Inbox ist leer + {/if} +

+

+ {#if viewStore.currentView === 'inbox'} + Erstelle deine erste Aufgabe mit dem + Button oben. + {/if} +

+
+ {:else} +
+ {#each displayTasks as task (task.id)} +
(selectedTaskId = selectedTaskId === task.id ? null : task.id)} + > + + + + +
+
+ + {task.title} + +
+ + +
+ {#if task.dueDate} + + {formatDueDate(task.dueDate)} + + {/if} + {#if task.priority !== 'medium'} + + {getPriorityLabel(task.priority)} + + {/if} + {#if task.subtasks?.length} + + {task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks.length} Teilaufgaben + + {/if} +
+ + + {#if selectedTaskId === task.id} +
+ {#if task.description} +

{task.description}

+ {/if} + + {#if task.subtasks?.length} +
+ {#each task.subtasks as subtask (subtask.id)} +
+ {#if subtask.isCompleted} + + {:else} + + {/if} + + {subtask.title} + +
+ {/each} +
+ {/if} + +
+ + + tasksStore.updateTask(task.id, { + dueDate: e.currentTarget.value + ? new Date(e.currentTarget.value).toISOString() + : null, + })} + class="rounded-md border border-border bg-background px-2 py-1 text-xs focus:border-primary focus:outline-none" + /> + +
+
+ {/if} +
+ + + {#if task.priority === 'urgent' || task.priority === 'high'} +
+ {/if} +
+ {/each} +
+ +

+ {displayTasks.length} Aufgabe{displayTasks.length !== 1 ? 'n' : ''} +

+ {/if} + + + {#if allProjects.length > 0} +
+

+ Projekte +

+
+ {#each allProjects as project (project.id)} + + {/each} +
+
+ {/if} + + + {#if allLabels.length > 0} +
+

+ Labels +

+
+ {#each allLabels as label (label.id)} + + {/each} +
+
+ {/if} +