feat(manacore): migrate contacts, todo, calendar, picture, chat, mukke, memoro — Phase 2 complete

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 20:48:30 +02:00
parent 933715c7d9
commit 9b614cdfbc
82 changed files with 10802 additions and 0 deletions

View file

@ -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<LocalCalendar>('calendars');
export const eventTable = db.table<LocalEvent>('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[];
})(),
};

View file

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

View file

@ -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<LocalCalendar>('calendars').toArray();
return locals.filter((c) => !c.deletedAt).map(toCalendar);
});
}
export function allEvents$() {
return liveQuery(async () => {
const locals = await db.table<LocalEvent>('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<LocalCalendar>('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<LocalEvent>('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()
);
}

View file

@ -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<string | null>(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<LocalCalendar>('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<Omit<LocalCalendar, 'id'>>) {
error = null;
try {
await db.table('calendars').update(id, {
...input,
updatedAt: new Date().toISOString(),
});
const updated = await db.table<LocalCalendar>('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<LocalCalendar>('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 };
}
},
};

View file

@ -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<string | null>(null);
let draftEvent = $state<CalendarEvent | null>(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<LocalEvent>('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<LocalEvent> = {
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<LocalEvent>('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<CalendarEvent>) {
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<CalendarEvent>) {
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;
},
};

View file

@ -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<CalendarViewType>('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;
}
},
};

View file

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

View file

@ -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<LocalConversation>('conversations');
export const messageTable = db.table<LocalMessage>('messages');
export const chatTemplateTable = db.table<LocalTemplate>('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<string, unknown>[],
};

View file

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

View file

@ -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<LocalConversation>('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<LocalConversation>('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<LocalTemplate>('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<LocalMessage>('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),
};
}

View file

@ -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<LocalConversation>) {
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 });
}
},
};

View file

@ -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 });
},
};

View file

@ -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(),
});
},
};

View file

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

View file

@ -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<LocalContact>('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[],
};

View file

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

View file

@ -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<LocalContact>('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<string, Contact[]> {
const groups: Record<string, Contact[]> = {};
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;
}

View file

@ -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<Contact>) {
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<Contact>) {
const updateData: Partial<LocalContact> = {};
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(),
});
},
};

View file

@ -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<ContactsFilterState>(DEFAULT_STATE);
function update<K extends keyof ContactsFilterState>(
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();
},
};

View file

@ -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<NewContactData | null>(null);
let editContactId = $state<string | null>(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;
},
};

View file

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

View file

@ -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<LocalMemo>('memos');
export const memoryTable = db.table<LocalMemory>('memories');
export const memoroTagTable = db.table<LocalTag>('memoroTags');
export const memoTagTable = db.table<LocalMemoTag>('memoTags');
export const memoroSpaceTable = db.table<LocalSpace>('memoroSpaces');
export const spaceMemberTable = db.table<LocalSpaceMember>('spaceMembers');
export const memoSpaceTable = db.table<LocalMemoSpace>('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<string, unknown>[],
spaceMembers: [] as Record<string, unknown>[],
memoSpaces: [] as Record<string, unknown>[],
};

View file

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

View file

@ -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<LocalMemo>('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<LocalMemo>('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<LocalMemory>('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<LocalTag>('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<LocalMemoTag>('memoTags').toArray();
return locals.filter((mt) => !mt.deletedAt);
});
}
/** All spaces. */
export function useAllSpaces() {
return liveQuery(async () => {
const locals = await db.table<LocalSpace>('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;
}
}

View file

@ -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<Pick<LocalMemory, 'title' | 'content'>>) {
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 });
},
};

View file

@ -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<Pick<LocalMemo, 'title' | 'intro' | 'transcript' | 'language' | 'isPublic'>>
) {
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 });
},
};

View file

@ -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<Pick<LocalTag, 'name' | 'color' | 'isPinned'>>) {
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 });
}
},
};

View file

@ -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<string, unknown>;
source?: {
audioPath?: string;
audioDeleted?: boolean;
audioDuration?: number;
transcript?: string;
utterances?: Array<{
text: string;
offset?: number;
duration?: number;
speakerId?: string;
}>;
speakers?: Record<string, unknown>;
speakerMap?: Record<string, string>;
primaryLanguage?: string;
languages?: string[];
processing?: {
transcription?: { status: ProcessingStatus };
headlineAndIntro?: { status: ProcessingStatus };
};
recordingStartedAt?: string;
};
metadata?: Record<string, unknown>;
}
export interface LocalMemory extends BaseRecord {
memoId: string;
userId?: string;
title: string;
content: string | null;
metadata?: Record<string, unknown>;
}
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;
}

View file

@ -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<LocalSong>('songs');
export const mukkePlaylistTable = db.table<LocalPlaylist>('mukkePlaylists');
export const playlistSongTable = db.table<LocalPlaylistSong>('playlistSongs');
export const mukkeProjectTable = db.table<LocalProject>('mukkeProjects');
export const markerTable = db.table<LocalMarker>('markers');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_PLAYLIST_ID = 'demo-favorites';
export const MUKKE_GUEST_SEED = {
songs: [] as Record<string, unknown>[],
mukkePlaylists: [
{
id: DEMO_PLAYLIST_ID,
name: 'Meine Favoriten',
description: 'Deine Lieblingssongs.',
coverArtPath: null,
},
],
playlistSongs: [] as Record<string, unknown>[],
mukkeProjects: [] as Record<string, unknown>[],
markers: [] as Record<string, unknown>[],
};

View file

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

View file

@ -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<LocalSong>('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<LocalPlaylist>('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<LocalPlaylistSong>('playlistSongs').toArray();
return locals.filter((ps) => !ps.deletedAt);
});
}
/** All projects, sorted by title. */
export function useAllProjects() {
return liveQuery(async () => {
const locals = await db.table<LocalProject>('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<LocalMarker>('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<string, { songCount: number; albumCount: number }>();
const artistAlbums = new Map<string, Set<string>>();
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<string, Album>();
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<string, number>();
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');
}

View file

@ -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);
},
};

View file

@ -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<T>(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<PlayerState>({
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();

View file

@ -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<Pick<LocalPlaylist, 'name' | 'description'>>) {
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 });
}
}
},
};

View file

@ -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<Pick<LocalProject, 'title' | 'description' | 'songId'>>) {
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 });
},
};

View file

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

View file

@ -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<LocalImage>('images');
export const boardTable = db.table<LocalBoard>('boards');
export const boardItemTable = db.table<LocalBoardItem>('boardItems');
export const pictureTagTable = db.table<LocalPictureTag>('pictureTags');
export const imageTagTable = db.table<LocalImageTag>('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[],
};

View file

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

View file

@ -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<LocalImage>('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<LocalImage>('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<LocalBoard>('boards').toArray();
const allItems = await db.table<LocalBoardItem>('boardItems').toArray();
const itemCounts = new Map<string, number>();
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<LocalPictureTag>('pictureTags').toArray();
return locals.filter((t) => !t.deletedAt);
}, [] as LocalPictureTag[]);
}
/** All image-tag associations. */
export function useAllImageTags() {
return useLiveQueryWithDefault(async () => {
return await db.table<LocalImageTag>('imageTags').toArray();
}, [] as LocalImageTag[]);
}
// ─── Raw Observable Queries ────────────────────────────────
export function allImages$() {
return liveQuery(async () => {
const locals = await db.table<LocalImage>('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<LocalBoard>('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);
}

View file

@ -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<string | null>(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<LocalBoard>('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<Omit<LocalBoard, 'id'>>) {
error = null;
try {
await db.table('boards').update(id, {
...input,
updatedAt: new Date().toISOString(),
});
const updated = await db.table<LocalBoard>('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<LocalBoardItem>('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<LocalBoard>('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<LocalBoard>('boards').add(duplicated);
// Duplicate board items
const originalItems = await db
.table<LocalBoardItem>('boardItems')
.where('boardId')
.equals(id)
.toArray();
for (const item of originalItems) {
if (item.deletedAt) continue;
await db.table<LocalBoardItem>('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 };
}
},
};

View file

@ -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<string | null>(null);
let selectedImageId = $state<string | null>(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 };
}
},
};

View file

@ -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<ViewMode>('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);
}
},
};

View file

@ -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<string, unknown>;
}
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;
}

View file

@ -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<LocalTask>('tasks');
export const todoProjectTable = db.table<LocalTodoProject>('todoProjects');
export const labelTable = db.table<LocalLabel>('labels');
export const taskLabelTable = db.table<LocalTaskLabel>('taskLabels');
export const reminderTable = db.table<LocalReminder>('reminders');
export const boardViewTable = db.table<LocalBoardView>('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[],
};

View file

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

View file

@ -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<string, unknown>).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<LocalTask>('tasks').orderBy('order').toArray();
return locals.filter((t) => !t.deletedAt).map(toTask);
});
}
export function useAllLabels() {
return liveQuery(async () => {
const locals = await db.table<LocalLabel>('labels').toArray();
return locals.filter((l) => !l.deletedAt);
});
}
export function useAllBoardViews() {
return liveQuery(async () => {
const locals = await db.table<LocalBoardView>('boardViews').orderBy('order').toArray();
return locals.filter((v) => !v.deletedAt);
});
}
export function useAllReminders() {
return liveQuery(async () => {
const locals = await db.table<LocalReminder>('reminders').toArray();
return locals.filter((r) => !r.deletedAt);
});
}
export function useAllProjects() {
return liveQuery(async () => {
const locals = await db.table<LocalTodoProject>('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<TaskPriority, number> = {
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<TaskPriority, string> = {
urgent: 'Dringend',
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
};
const PRIORITY_COLORS: Record<TaskPriority, string> = {
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 };
}

View file

@ -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<LocalBoardView, 'id'>) {
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<LocalBoardView>) {
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(),
});
},
};

View file

@ -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<Pick<LocalLabel, 'name' | 'color'>>) {
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(),
});
},
};

View file

@ -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<string, unknown>).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(),
});
}
},
};

View file

@ -0,0 +1,101 @@
/**
* View Store Manages current view state using Svelte 5 runes.
*/
import type { ViewType, SortBy, SortOrder } from '../types';
let currentView = $state<ViewType>('inbox');
let currentLabelId = $state<string | null>(null);
let currentProjectId = $state<string | null>(null);
let searchQuery = $state('');
let sortBy = $state<SortBy>('order');
let sortOrder = $state<SortOrder>('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;
},
};

View file

@ -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<string, unknown>;
}
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<string, unknown> | 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';

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllCalendars, useAllEvents } from '$lib/modules/calendar/queries';
import { calendarViewStore } from '$lib/modules/calendar/stores/view.svelte';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allCalendars = useAllCalendars();
const allEvents = useAllEvents();
// Provide data to child components via Svelte context
setContext('calendars', allCalendars);
setContext('calendarEvents', allEvents);
// Initialize view preferences
calendarViewStore.initialize();
</script>
{@render children()}

View file

@ -0,0 +1,499 @@
<script lang="ts">
import { getContext } from 'svelte';
import { calendarViewStore } from '$lib/modules/calendar/stores/view.svelte';
import { eventsStore } from '$lib/modules/calendar/stores/events.svelte';
import {
getDefaultCalendar,
getEventsForDay,
getEventsInRange,
filterEventsByVisibleCalendars,
sortEventsByTime,
getCalendarColor,
} from '$lib/modules/calendar/queries';
import type { Calendar, CalendarEvent } from '$lib/modules/calendar/types';
import {
format,
addMinutes,
eachDayOfInterval,
startOfWeek,
endOfWeek,
isSameDay,
isToday,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { CaretLeft, CaretRight, Plus } from '@manacore/shared-icons';
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
// Filtered events based on visible calendars
let visibleEvents = $derived(filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value));
// Current view range events
let rangeEvents = $derived(
sortEventsByTime(
getEventsInRange(
visibleEvents,
calendarViewStore.viewRange.start,
calendarViewStore.viewRange.end
)
)
);
// Week days for the week view
let weekDays = $derived(
eachDayOfInterval({
start: startOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }),
end: endOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }),
})
);
// Event form state
let showEventForm = $state(false);
let editingEvent = $state<CalendarEvent | null>(null);
let newTitle = $state('');
let newDate = $state('');
let newStartTime = $state('10:00');
let newEndTime = $state('11:00');
let newAllDay = $state(false);
let newLocation = $state('');
function openNewEvent(date?: Date) {
const d = date ?? new Date();
editingEvent = null;
newTitle = '';
newDate = format(d, 'yyyy-MM-dd');
newStartTime = '10:00';
newEndTime = '11:00';
newAllDay = false;
newLocation = '';
showEventForm = true;
}
function openEditEvent(event: CalendarEvent) {
editingEvent = event;
newTitle = event.title;
newDate = format(new Date(event.startTime), 'yyyy-MM-dd');
newStartTime = format(new Date(event.startTime), 'HH:mm');
newEndTime = format(new Date(event.endTime), 'HH:mm');
newAllDay = event.isAllDay;
newLocation = event.location ?? '';
showEventForm = true;
}
async function handleSaveEvent() {
const defaultCal = getDefaultCalendar(calendarsCtx.value);
const startTime = newAllDay ? `${newDate}T00:00:00` : `${newDate}T${newStartTime}:00`;
const endTime = newAllDay ? `${newDate}T23:59:59` : `${newDate}T${newEndTime}:00`;
if (editingEvent) {
await eventsStore.updateEvent(editingEvent.id, {
title: newTitle,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
isAllDay: newAllDay,
location: newLocation || null,
});
} else {
await eventsStore.createEvent({
calendarId: defaultCal?.id || '',
title: newTitle,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
isAllDay: newAllDay,
location: newLocation || null,
});
}
showEventForm = false;
}
async function handleDeleteEvent() {
if (!editingEvent) return;
await eventsStore.deleteEvent(editingEvent.id);
showEventForm = false;
}
// Hours for the week grid
const hours = Array.from({ length: 24 }, (_, i) => i);
let headerLabel = $derived.by(() => {
if (calendarViewStore.viewType === 'month') {
return format(calendarViewStore.currentDate, 'MMMM yyyy', { locale: de });
}
return format(calendarViewStore.currentDate, "'KW' w — MMMM yyyy", { locale: de });
});
</script>
<svelte:head>
<title>Kalender - ManaCore</title>
</svelte:head>
<div class="flex h-full flex-col">
<!-- Header -->
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-foreground">{headerLabel}</h1>
<div class="flex items-center gap-1">
<button
onclick={() => calendarViewStore.goToPrevious()}
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<CaretLeft size={18} />
</button>
<button
onclick={() => calendarViewStore.goToToday()}
class="rounded-lg px-3 py-1 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
Heute
</button>
<button
onclick={() => calendarViewStore.goToNext()}
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<CaretRight size={18} />
</button>
</div>
</div>
<div class="flex items-center gap-2">
<!-- View Type Switcher -->
<div class="flex rounded-lg border border-border bg-card">
{#each ['week', 'month', 'agenda'] as view}
<button
onclick={() => calendarViewStore.setViewType(view as 'week' | 'month' | 'agenda')}
class="px-3 py-1.5 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg {calendarViewStore.viewType ===
view
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
{view === 'week' ? 'Woche' : view === 'month' ? 'Monat' : 'Agenda'}
</button>
{/each}
</div>
<!-- New Event Button -->
<button
onclick={() => openNewEvent()}
class="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Termin
</button>
</div>
</header>
<!-- View Content -->
<div class="flex-1 overflow-auto">
{#if calendarViewStore.viewType === 'week'}
<!-- Week View -->
<div class="week-grid">
<!-- Day Headers -->
<div
class="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,1fr)] border-b border-border bg-card"
>
<div class="p-2"></div>
{#each weekDays as day}
<button
onclick={() => {
calendarViewStore.setDate(day);
}}
class="border-l border-border p-2 text-center {isToday(day) ? 'bg-primary/10' : ''}"
>
<div class="text-xs text-muted-foreground">
{format(day, 'EEE', { locale: de })}
</div>
<div
class="text-lg font-semibold {isToday(day) ? 'text-primary' : 'text-foreground'}"
>
{format(day, 'd')}
</div>
</button>
{/each}
</div>
<!-- Time Grid -->
<div class="relative">
{#each hours as hour}
<div class="grid grid-cols-[60px_repeat(7,1fr)] border-b border-border/50">
<div class="p-1 pr-2 text-right text-xs text-muted-foreground">
{hour.toString().padStart(2, '0')}:00
</div>
{#each weekDays as day}
<button
onclick={() => {
const d = new Date(day);
d.setHours(hour, 0, 0, 0);
openNewEvent(d);
}}
class="h-12 border-l border-border/50 hover:bg-muted/50 transition-colors relative"
>
<!-- Render events at this slot -->
{#each getEventsForDay(visibleEvents, day).filter((e) => {
const h = new Date(e.startTime).getHours();
return h === hour && !e.isAllDay;
}) as event}
<div
class="absolute inset-x-0.5 top-0 z-10 rounded px-1 py-0.5 text-xs text-white truncate cursor-pointer"
style="background-color: {getCalendarColor(
calendarsCtx.value,
event.calendarId
)}"
role="button"
tabindex="0"
onclick|stopPropagation={() => openEditEvent(event)}
onkeydown={(e) => e.key === 'Enter' && openEditEvent(event)}
>
{event.title}
</div>
{/each}
</button>
{/each}
</div>
{/each}
</div>
</div>
{:else if calendarViewStore.viewType === 'month'}
<!-- Month View -->
<div class="p-4">
<div
class="grid grid-cols-7 gap-px rounded-lg border border-border bg-border overflow-hidden"
>
<!-- Day name headers -->
{#each ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as dayName}
<div class="bg-card p-2 text-center text-xs font-medium text-muted-foreground">
{dayName}
</div>
{/each}
<!-- Calendar days -->
{#each eachDayOfInterval( { start: startOfWeek( calendarViewStore.viewRange.start, { weekStartsOn: 1 } ), end: endOfWeek( calendarViewStore.viewRange.end, { weekStartsOn: 1 } ) } ) as day}
<button
onclick={() => {
calendarViewStore.setDate(day);
calendarViewStore.setViewType('week');
}}
class="min-h-[80px] bg-card p-1 text-left hover:bg-muted/50 transition-colors {day.getMonth() !==
calendarViewStore.currentDate.getMonth()
? 'opacity-40'
: ''}"
>
<div
class="mb-1 text-xs font-medium {isToday(day)
? 'flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground'
: 'text-foreground'}"
>
{format(day, 'd')}
</div>
{#each getEventsForDay(visibleEvents, day).slice(0, 3) as event}
<div
class="mb-0.5 truncate rounded px-1 text-[10px] text-white"
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
>
{event.title}
</div>
{/each}
{#if getEventsForDay(visibleEvents, day).length > 3}
<div class="text-[10px] text-muted-foreground">
+{getEventsForDay(visibleEvents, day).length - 3} weitere
</div>
{/if}
</button>
{/each}
</div>
</div>
{:else}
<!-- Agenda View -->
<div class="mx-auto max-w-2xl p-4">
{#if rangeEvents.length === 0}
<div class="py-16 text-center">
<p class="text-lg text-muted-foreground">Keine Termine in den nächsten 30 Tagen</p>
<button
onclick={() => openNewEvent()}
class="mt-4 text-sm text-primary hover:underline"
>
Termin erstellen
</button>
</div>
{: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<string, CalendarEvent[]>
)}
{#each Object.entries(groupedByDate) as [dateKey, dayEvents]}
<div class="mb-6">
<h3 class="mb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{format(new Date(dateKey), 'EEEE, d. MMMM', { locale: de })}
{#if isToday(new Date(dateKey))}
<span class="ml-2 text-primary">Heute</span>
{/if}
</h3>
<div class="space-y-2">
{#each dayEvents as event (event.id)}
<button
onclick={() => openEditEvent(event)}
class="flex w-full items-start gap-3 rounded-lg border border-border bg-card p-3 text-left hover:border-primary/50 transition-colors"
>
<div
class="mt-1 h-3 w-3 flex-shrink-0 rounded-full"
style="background-color: {getCalendarColor(
calendarsCtx.value,
event.calendarId
)}"
></div>
<div class="flex-1 min-w-0">
<div class="font-medium text-foreground">{event.title}</div>
<div class="text-sm text-muted-foreground">
{#if event.isAllDay}
Ganztägig
{:else}
{format(new Date(event.startTime), 'HH:mm')} {format(
new Date(event.endTime),
'HH:mm'
)}
{/if}
{#if event.location}
<span class="ml-2">📍 {event.location}</span>
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
</div>
<!-- Event Form Modal -->
{#if showEventForm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl">
<h2 class="mb-4 text-xl font-semibold text-foreground">
{editingEvent ? 'Termin bearbeiten' : 'Neuer Termin'}
</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleSaveEvent();
}}
class="space-y-4"
>
<div>
<label for="event-title" class="mb-1 block text-sm font-medium text-foreground">
Titel
</label>
<input
id="event-title"
type="text"
bind:value={newTitle}
placeholder="Termin-Titel"
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="event-date" class="mb-1 block text-sm font-medium text-foreground">
Datum
</label>
<input
id="event-date"
type="date"
bind:value={newDate}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={newAllDay} class="rounded" />
Ganztägig
</label>
{#if !newAllDay}
<div class="grid grid-cols-2 gap-3">
<div>
<label for="event-start" class="mb-1 block text-sm font-medium text-foreground"
>Von</label
>
<input
id="event-start"
type="time"
bind:value={newStartTime}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="event-end" class="mb-1 block text-sm font-medium text-foreground"
>Bis</label
>
<input
id="event-end"
type="time"
bind:value={newEndTime}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{/if}
<div>
<label for="event-location" class="mb-1 block text-sm font-medium text-foreground">
Ort (optional)
</label>
<input
id="event-location"
type="text"
bind:value={newLocation}
placeholder="Ort eingeben..."
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-3 pt-2">
{#if editingEvent}
<button
type="button"
onclick={handleDeleteEvent}
class="rounded-lg px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20 transition-colors"
>
Löschen
</button>
{/if}
<div class="flex-1"></div>
<button
type="button"
onclick={() => (showEventForm = false)}
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!newTitle.trim()}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Speichern
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.week-grid {
min-height: 100%;
}
</style>

View file

@ -0,0 +1,185 @@
<script lang="ts">
import { getContext } from 'svelte';
import { calendarsStore } from '$lib/modules/calendar/stores/calendars.svelte';
import type { Calendar } from '$lib/modules/calendar/types';
import { CaretLeft, Plus, Trash, Eye, EyeSlash, Star } from '@manacore/shared-icons';
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// Create calendar form
let showCreateForm = $state(false);
let newName = $state('');
let newColor = $state('#3B82F6');
const PRESET_COLORS = [
'#3B82F6',
'#EF4444',
'#10B981',
'#F59E0B',
'#8B5CF6',
'#EC4899',
'#14B8A6',
'#F97316',
];
async function handleCreate() {
if (!newName.trim()) return;
await calendarsStore.createCalendar({
name: newName,
color: newColor,
});
newName = '';
newColor = '#3B82F6';
showCreateForm = false;
}
async function handleToggleVisibility(id: string) {
await calendarsStore.toggleVisibility(id, calendarsCtx.value);
}
async function handleSetDefault(id: string) {
await calendarsStore.setAsDefault(id, calendarsCtx.value);
}
async function handleDelete(id: string) {
if (!confirm('Kalender wirklich löschen? Alle zugehörigen Termine gehen verloren.')) return;
await calendarsStore.deleteCalendar(id);
}
</script>
<svelte:head>
<title>Kalender verwalten - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl p-4">
<a
href="/calendar"
class="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<CaretLeft size={16} />
Zurück zum Kalender
</a>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">Meine Kalender</h1>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Neuer Kalender
</button>
</div>
<!-- Create Form -->
{#if showCreateForm}
<div class="mb-6 rounded-xl border border-border bg-card p-4">
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="space-y-3"
>
<div>
<label for="cal-name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
<input
id="cal-name"
type="text"
bind:value={newName}
placeholder="z.B. Arbeit, Sport, Familie..."
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-foreground">Farbe</label>
<div class="flex gap-2">
{#each PRESET_COLORS as color}
<button
type="button"
onclick={() => (newColor = color)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {newColor ===
color
? 'border-foreground scale-110'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
</div>
<div class="flex gap-2 pt-1">
<button
type="button"
onclick={() => (showCreateForm = false)}
class="flex-1 rounded-lg border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!newName.trim()}
class="flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Erstellen
</button>
</div>
</form>
</div>
{/if}
<!-- Calendar List -->
<div class="space-y-2">
{#if calendarsCtx.value.length === 0}
<div class="py-12 text-center text-muted-foreground">Noch keine Kalender vorhanden.</div>
{:else}
{#each calendarsCtx.value as cal (cal.id)}
<div class="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
<div
class="h-4 w-4 flex-shrink-0 rounded-full"
style="background-color: {cal.color}"
></div>
<div class="flex-1 min-w-0">
<div class="font-medium text-foreground">
{cal.name}
{#if cal.isDefault}
<span class="ml-1 text-xs text-primary">(Standard)</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<button
onclick={() => handleToggleVisibility(cal.id)}
class="rounded-lg p-1.5 text-muted-foreground hover:text-foreground transition-colors"
title={cal.isVisible ? 'Ausblenden' : 'Einblenden'}
>
{#if cal.isVisible}
<Eye size={16} />
{:else}
<EyeSlash size={16} />
{/if}
</button>
{#if !cal.isDefault}
<button
onclick={() => handleSetDefault(cal.id)}
class="rounded-lg p-1.5 text-muted-foreground hover:text-amber-500 transition-colors"
title="Als Standard setzen"
>
<Star size={16} />
</button>
{/if}
<button
onclick={() => handleDelete(cal.id)}
class="rounded-lg p-1.5 text-muted-foreground hover:text-red-600 transition-colors"
title="Löschen"
>
<Trash size={16} />
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>

View file

@ -0,0 +1,269 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { eventsStore } from '$lib/modules/calendar/stores/events.svelte';
import { getEventById, getCalendarById, getCalendarColor } from '$lib/modules/calendar/queries';
import type { Calendar, CalendarEvent } from '$lib/modules/calendar/types';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import { CaretLeft, Trash, PencilSimple, MapPin, Clock } from '@manacore/shared-icons';
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
let eventId = $derived($page.params.id);
let event = $derived(getEventById(eventsCtx.value, eventId));
let calendar = $derived(
event ? getCalendarById(calendarsCtx.value, event.calendarId) : undefined
);
// Edit state
let isEditing = $state(false);
let editTitle = $state('');
let editDate = $state('');
let editStartTime = $state('');
let editEndTime = $state('');
let editAllDay = $state(false);
let editLocation = $state('');
let editDescription = $state('');
function startEditing() {
if (!event) return;
editTitle = event.title;
editDate = format(new Date(event.startTime), 'yyyy-MM-dd');
editStartTime = format(new Date(event.startTime), 'HH:mm');
editEndTime = format(new Date(event.endTime), 'HH:mm');
editAllDay = event.isAllDay;
editLocation = event.location ?? '';
editDescription = event.description ?? '';
isEditing = true;
}
async function handleSave() {
if (!event) return;
const startTime = editAllDay ? `${editDate}T00:00:00` : `${editDate}T${editStartTime}:00`;
const endTime = editAllDay ? `${editDate}T23:59:59` : `${editDate}T${editEndTime}:00`;
await eventsStore.updateEvent(event.id, {
title: editTitle,
description: editDescription || null,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
isAllDay: editAllDay,
location: editLocation || null,
});
isEditing = false;
}
async function handleDelete() {
if (!event) return;
await eventsStore.deleteEvent(event.id);
goto('/calendar');
}
</script>
<svelte:head>
<title>{event?.title ?? 'Termin'} - Kalender - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl p-4">
<!-- Back Button -->
<button
onclick={() => goto('/calendar')}
class="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<CaretLeft size={16} />
Zurück zum Kalender
</button>
{#if !event}
<div class="py-16 text-center">
<p class="text-lg text-muted-foreground">Termin nicht gefunden</p>
<button onclick={() => goto('/calendar')} class="mt-4 text-sm text-primary hover:underline">
Zurück zum Kalender
</button>
</div>
{:else if isEditing}
<!-- Edit Form -->
<div class="rounded-xl border border-border bg-card p-6">
<h2 class="mb-4 text-xl font-semibold text-foreground">Termin bearbeiten</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
class="space-y-4"
>
<div>
<label for="edit-title" class="mb-1 block text-sm font-medium text-foreground"
>Titel</label
>
<input
id="edit-title"
type="text"
bind:value={editTitle}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="edit-desc" class="mb-1 block text-sm font-medium text-foreground"
>Beschreibung</label
>
<textarea
id="edit-desc"
bind:value={editDescription}
rows="3"
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
></textarea>
</div>
<div>
<label for="edit-date" class="mb-1 block text-sm font-medium text-foreground">Datum</label
>
<input
id="edit-date"
type="date"
bind:value={editDate}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={editAllDay} class="rounded" />
Ganztägig
</label>
{#if !editAllDay}
<div class="grid grid-cols-2 gap-3">
<div>
<label for="edit-start" class="mb-1 block text-sm font-medium text-foreground"
>Von</label
>
<input
id="edit-start"
type="time"
bind:value={editStartTime}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="edit-end" class="mb-1 block text-sm font-medium text-foreground"
>Bis</label
>
<input
id="edit-end"
type="time"
bind:value={editEndTime}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{/if}
<div>
<label for="edit-location" class="mb-1 block text-sm font-medium text-foreground"
>Ort</label
>
<input
id="edit-location"
type="text"
bind:value={editLocation}
placeholder="Ort eingeben..."
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-3 pt-2">
<button
type="button"
onclick={() => (isEditing = false)}
class="flex-1 rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Abbrechen
</button>
<button
type="submit"
class="flex-1 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Speichern
</button>
</div>
</form>
</div>
{:else}
<!-- Event Detail View -->
<div class="rounded-xl border border-border bg-card p-6">
<div class="mb-4 flex items-start justify-between">
<div class="flex items-start gap-3">
<div
class="mt-1 h-4 w-4 flex-shrink-0 rounded-full"
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
></div>
<div>
<h1 class="text-2xl font-bold text-foreground">{event.title}</h1>
{#if calendar}
<p class="text-sm text-muted-foreground">{calendar.name}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<button
onclick={startEditing}
class="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="Bearbeiten"
>
<PencilSimple size={18} />
</button>
<button
onclick={handleDelete}
class="rounded-lg p-2 text-muted-foreground hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/20 transition-colors"
title="Löschen"
>
<Trash size={18} />
</button>
</div>
</div>
<div class="space-y-3">
<!-- Date & Time -->
<div class="flex items-center gap-2 text-foreground">
<Clock size={18} class="text-muted-foreground" />
<div>
<div>{format(new Date(event.startTime), 'EEEE, d. MMMM yyyy', { locale: de })}</div>
{#if event.isAllDay}
<div class="text-sm text-muted-foreground">Ganztägig</div>
{:else}
<div class="text-sm text-muted-foreground">
{format(new Date(event.startTime), 'HH:mm')} {format(
new Date(event.endTime),
'HH:mm'
)} Uhr
</div>
{/if}
</div>
</div>
<!-- Location -->
{#if event.location}
<div class="flex items-center gap-2 text-foreground">
<MapPin size={18} class="text-muted-foreground" />
<span>{event.location}</span>
</div>
{/if}
<!-- Description -->
{#if event.description}
<div class="mt-4 border-t border-border pt-4">
<p class="whitespace-pre-wrap text-foreground">{event.description}</p>
</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import {
useAllConversations,
useArchivedConversations,
useAllTemplates,
} from '$lib/modules/chat/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes
const allConversations = useAllConversations();
const archivedConversations = useArchivedConversations();
const allTemplates = useAllTemplates();
// Provide data to child components via Svelte context
setContext('conversations', allConversations);
setContext('archivedConversations', archivedConversations);
setContext('templates', allTemplates);
</script>
{@render children()}

View file

@ -0,0 +1,242 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { conversationsStore } from '$lib/modules/chat/stores/conversations.svelte';
import { messagesStore } from '$lib/modules/chat/stores/messages.svelte';
import { filterBySearch, splitPinned } from '$lib/modules/chat/queries';
import type { Conversation, Template } from '$lib/modules/chat/types';
import {
Plus,
ChatCircle,
PushPin,
Archive,
MagnifyingGlass,
Sparkle,
} from '@manacore/shared-icons';
const conversationsCtx: { readonly value: Conversation[] } = getContext('conversations');
const templatesCtx: { readonly value: Template[] } = getContext('templates');
let searchQuery = $state('');
let filtered = $derived(filterBySearch(conversationsCtx.value, searchQuery));
let { pinned, unpinned } = $derived(splitPinned(filtered));
async function handleNewChat() {
const conversation = await conversationsStore.create({});
goto(`/chat/${conversation.id}`);
}
function handleConversationClick(id: string) {
goto(`/chat/${id}`);
}
async function handlePin(e: Event, id: string, isPinned: boolean) {
e.stopPropagation();
if (isPinned) {
await conversationsStore.unpin(id);
} else {
await conversationsStore.pin(id);
}
}
async function handleArchive(e: Event, id: string) {
e.stopPropagation();
await conversationsStore.archive(id);
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Heute';
if (days === 1) return 'Gestern';
if (days < 7) return `vor ${days} Tagen`;
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
}
</script>
<svelte:head>
<title>Chat - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Chat</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{conversationsCtx.value.length} Konversationen
</p>
</div>
<div class="flex items-center gap-2">
<a
href="/chat/templates"
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm font-medium text-[hsl(var(--foreground))] transition-colors hover:bg-[hsl(var(--muted))]"
>
Vorlagen
</a>
<button
onclick={handleNewChat}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
<Plus size={20} />
Neuer Chat
</button>
</div>
</div>
<!-- Search -->
<div class="relative">
<MagnifyingGlass
size={18}
class="absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
/>
<input
type="text"
placeholder="Konversationen durchsuchen..."
bind:value={searchQuery}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] py-2.5 pl-10 pr-4 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Conversations -->
{#if conversationsCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[hsl(var(--primary)/0.1)]"
>
<Sparkle size={32} weight="duotone" class="text-[hsl(var(--primary))]" />
</div>
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">
Starte deine erste Unterhaltung
</h2>
<p class="mb-6 text-sm text-[hsl(var(--muted-foreground))]">
Stelle eine Frage oder bitte um Hilfe bei einem Projekt.
</p>
<button
onclick={handleNewChat}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
Neuer Chat
</button>
</div>
{:else}
<div class="space-y-4">
<!-- Pinned -->
{#if pinned.length > 0}
<div>
<h3
class="mb-2 text-xs font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]"
>
Angepinnt
</h3>
<div class="space-y-1">
{#each pinned as conv (conv.id)}
<div
role="button"
tabindex="0"
onclick={() => 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)]"
>
<ChatCircle size={20} class="shrink-0 text-[hsl(var(--primary))]" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-[hsl(var(--foreground))]">
{conv.title || 'Neue Konversation'}
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{formatDate(conv.updatedAt)}
</p>
</div>
<div
class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={(e) => handlePin(e, conv.id, true)}
class="rounded p-1 text-[hsl(var(--primary))] hover:bg-[hsl(var(--muted))]"
title="Loslösen"
>
<PushPin size={16} weight="fill" />
</button>
<button
onclick={(e) => handleArchive(e, conv.id)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
title="Archivieren"
>
<Archive size={16} />
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Recent -->
{#if unpinned.length > 0}
<div>
{#if pinned.length > 0}
<h3
class="mb-2 text-xs font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]"
>
Zuletzt
</h3>
{/if}
<div class="space-y-1">
{#each unpinned as conv (conv.id)}
<div
role="button"
tabindex="0"
onclick={() => 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))]"
>
<ChatCircle size={20} class="shrink-0 text-[hsl(var(--muted-foreground))]" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-[hsl(var(--foreground))]">
{conv.title || 'Neue Konversation'}
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{formatDate(conv.updatedAt)}
</p>
</div>
<div
class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={(e) => handlePin(e, conv.id, false)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
title="Anpinnen"
>
<PushPin size={16} />
</button>
<button
onclick={(e) => handleArchive(e, conv.id)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
title="Archivieren"
>
<Archive size={16} />
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Archive link -->
<div class="pt-2">
<a
href="/chat/archive"
class="inline-flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<Archive size={16} />
Archiv anzeigen
</a>
</div>
</div>

View file

@ -0,0 +1,216 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { conversationsStore } from '$lib/modules/chat/stores/conversations.svelte';
import { messagesStore } from '$lib/modules/chat/stores/messages.svelte';
import { useConversationMessages } from '$lib/modules/chat/queries';
import type { Conversation, Message } from '$lib/modules/chat/types';
import {
PaperPlaneRight,
ArrowLeft,
Trash,
PushPin,
PencilSimple,
Check,
X,
} from '@manacore/shared-icons';
const conversationsCtx: { readonly value: Conversation[] } = getContext('conversations');
const conversationId = $derived($page.params.id ?? '');
const conversation = $derived(conversationsCtx.value.find((c) => c.id === conversationId));
// Live query for messages of this conversation
const messagesQuery = $derived(useConversationMessages(conversationId));
let messages = $derived((messagesQuery as { value: Message[] })?.value ?? []);
let inputText = $state('');
let isSending = $state(false);
let isEditingTitle = $state(false);
let editTitle = $state('');
async function handleSend() {
const text = inputText.trim();
if (!text || isSending) return;
isSending = true;
inputText = '';
try {
// Add user message to IndexedDB
await messagesStore.addUserMessage(conversationId, text);
// Auto-set title if first message and no title
if (messages.length <= 1 && !conversation?.title) {
const title = text.length > 50 ? text.substring(0, 50) + '...' : text;
await conversationsStore.updateTitle(conversationId, title);
}
// NOTE: In the standalone chat app, this would call the chat backend for AI response.
// In the unified app, the chat compute server handles streaming completions.
// For now, messages are stored locally. AI integration will be added via
// the chat compute server at /api/v1/chat/completions.
} catch (e) {
console.error('Failed to send message:', e);
} finally {
isSending = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function startEditTitle() {
editTitle = conversation?.title ?? '';
isEditingTitle = true;
}
async function saveTitle() {
if (editTitle.trim()) {
await conversationsStore.updateTitle(conversationId, editTitle.trim());
}
isEditingTitle = false;
}
function cancelEditTitle() {
isEditingTitle = false;
}
async function handleDelete() {
if (confirm('Konversation wirklich loschen?')) {
await conversationsStore.delete(conversationId);
goto('/chat');
}
}
async function togglePin() {
if (!conversation) return;
if (conversation.isPinned) {
await conversationsStore.unpin(conversationId);
} else {
await conversationsStore.pin(conversationId);
}
}
</script>
<svelte:head>
<title>{conversation?.title || 'Chat'} - ManaCore</title>
</svelte:head>
<div class="flex h-full flex-col">
<!-- Header -->
<div
class="flex items-center gap-3 border-b border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
>
<a
href="/chat"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<div class="min-w-0 flex-1">
{#if isEditingTitle}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={editTitle}
onkeydown={(e) => 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))]"
/>
<button onclick={saveTitle} class="text-[hsl(var(--primary))]">
<Check size={18} />
</button>
<button onclick={cancelEditTitle} class="text-[hsl(var(--muted-foreground))]">
<X size={18} />
</button>
</div>
{:else}
<button onclick={startEditTitle} class="group flex items-center gap-1.5 text-left">
<h1 class="truncate text-sm font-semibold text-[hsl(var(--foreground))]">
{conversation?.title || 'Neue Konversation'}
</h1>
<PencilSimple
size={14}
class="shrink-0 text-[hsl(var(--muted-foreground))] opacity-0 group-hover:opacity-100"
/>
</button>
{/if}
</div>
<div class="flex items-center gap-1">
<button
onclick={togglePin}
class="rounded-lg p-1.5 transition-colors {conversation?.isPinned
? 'text-[hsl(var(--primary))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'}"
title={conversation?.isPinned ? 'Loslösen' : 'Anpinnen'}
>
<PushPin size={18} weight={conversation?.isPinned ? 'fill' : 'regular'} />
</button>
<button
onclick={handleDelete}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title="Loschen"
>
<Trash size={18} />
</button>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto px-4 py-6">
{#if messages.length === 0}
<div class="flex h-full items-center justify-center">
<p class="text-sm text-[hsl(var(--muted-foreground))]">
Schreibe eine Nachricht, um die Unterhaltung zu starten.
</p>
</div>
{:else}
<div class="mx-auto max-w-3xl space-y-4">
{#each messages as msg (msg.id)}
<div class="flex {msg.sender === 'user' ? 'justify-end' : 'justify-start'}">
<div
class="max-w-[80%] rounded-2xl px-4 py-2.5 text-sm {msg.sender === 'user'
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] text-[hsl(var(--foreground))]'}"
>
<p class="whitespace-pre-wrap">{msg.messageText}</p>
<p class="mt-1 text-[10px] opacity-60">
{new Date(msg.createdAt).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Input -->
<div class="border-t border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div class="mx-auto flex max-w-3xl items-end gap-3">
<textarea
bind:value={inputText}
onkeydown={handleKeydown}
placeholder="Nachricht schreiben..."
rows="1"
class="flex-1 resize-none rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-4 py-3 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
<button
onclick={handleSend}
disabled={!inputText.trim() || isSending}
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90 disabled:opacity-50"
>
<PaperPlaneRight size={20} />
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { conversationsStore } from '$lib/modules/chat/stores/conversations.svelte';
import type { Conversation } from '$lib/modules/chat/types';
import { ArrowLeft, ArrowCounterClockwise, Trash, ChatCircle } from '@manacore/shared-icons';
const archivedCtx: { readonly value: Conversation[] } = getContext('archivedConversations');
async function handleUnarchive(e: Event, id: string) {
e.stopPropagation();
await conversationsStore.unarchive(id);
}
async function handleDelete(e: Event, id: string) {
e.stopPropagation();
if (confirm('Konversation endgultig loschen?')) {
await conversationsStore.delete(id);
}
}
function handleClick(id: string) {
goto(`/chat/${id}`);
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>Archiv - Chat - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<a
href="/chat"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Archiv</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{archivedCtx.value.length} archivierte Konversationen
</p>
</div>
</div>
{#if archivedCtx.value.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<ChatCircle size={48} class="mb-4 text-[hsl(var(--muted-foreground))]" />
<p class="text-[hsl(var(--muted-foreground))]">Keine archivierten Konversationen</p>
</div>
{:else}
<div class="space-y-1">
{#each archivedCtx.value as conv (conv.id)}
<div
role="button"
tabindex="0"
onclick={() => 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))]"
>
<ChatCircle size={20} class="shrink-0 text-[hsl(var(--muted-foreground))]" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-[hsl(var(--foreground))]">
{conv.title || 'Konversation ohne Titel'}
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{formatDate(conv.updatedAt)}
</p>
</div>
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onclick={(e) => handleUnarchive(e, conv.id)}
class="rounded p-1.5 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--primary))]"
title="Wiederherstellen"
>
<ArrowCounterClockwise size={16} />
</button>
<button
onclick={(e) => handleDelete(e, conv.id)}
class="rounded p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title="Endgultig loschen"
>
<Trash size={16} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,310 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { templatesStore } from '$lib/modules/chat/stores/templates.svelte';
import { conversationsStore } from '$lib/modules/chat/stores/conversations.svelte';
import type { Template } from '$lib/modules/chat/types';
import {
ArrowLeft,
Plus,
Trash,
PencilSimple,
Star,
Play,
FileText,
X,
} from '@manacore/shared-icons';
const templatesCtx: { readonly value: Template[] } = getContext('templates');
let templates = $derived(templatesCtx.value);
let showForm = $state(false);
let editingId = $state<string | null>(null);
let formName = $state('');
let formDescription = $state('');
let formSystemPrompt = $state('');
let formColor = $state('#3b82f6');
let formDocumentMode = $state(false);
const COLORS = [
'#3b82f6',
'#8b5cf6',
'#ec4899',
'#f97316',
'#10b981',
'#06b6d4',
'#6366f1',
'#ef4444',
];
function openCreateForm() {
editingId = null;
formName = '';
formDescription = '';
formSystemPrompt = '';
formColor = '#3b82f6';
formDocumentMode = false;
showForm = true;
}
function openEditForm(template: Template) {
editingId = template.id;
formName = template.name;
formDescription = template.description ?? '';
formSystemPrompt = template.systemPrompt;
formColor = template.color;
formDocumentMode = template.documentMode;
showForm = true;
}
async function handleSubmit() {
if (!formName.trim() || !formSystemPrompt.trim()) return;
if (editingId) {
await templatesStore.update(editingId, {
name: formName.trim(),
description: formDescription.trim(),
systemPrompt: formSystemPrompt.trim(),
color: formColor,
documentMode: formDocumentMode,
});
} else {
await templatesStore.create({
name: formName.trim(),
description: formDescription.trim(),
systemPrompt: formSystemPrompt.trim(),
color: formColor,
documentMode: formDocumentMode,
});
}
showForm = false;
}
async function handleDelete(id: string) {
if (confirm('Vorlage wirklich loschen?')) {
await templatesStore.delete(id);
}
}
async function handleSetDefault(id: string) {
await templatesStore.setDefault(id);
}
async function handleUse(template: Template) {
const conversation = await conversationsStore.create({
templateId: template.id,
modelId: template.modelId ?? undefined,
mode: 'template',
documentMode: template.documentMode,
});
goto(`/chat/${conversation.id}`);
}
</script>
<svelte:head>
<title>Vorlagen - Chat - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/chat"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Vorlagen</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
Erstelle Vorlagen mit benutzerdefinierten System-Prompts.
</p>
</div>
</div>
<button
onclick={openCreateForm}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<Plus size={20} />
Neue Vorlage
</button>
</div>
<!-- Templates Grid -->
{#if templates.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<FileText size={48} class="mb-4 text-[hsl(var(--muted-foreground))]" />
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">Keine Vorlagen</h2>
<p class="mb-6 text-sm text-[hsl(var(--muted-foreground))]">
Erstelle deine erste Vorlage, um loszulegen.
</p>
<button
onclick={openCreateForm}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
Neue Vorlage
</button>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
{#each templates as template (template.id)}
<div
class="group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="h-3 w-3 rounded-full" style="background-color: {template.color}"></div>
<div>
<h3 class="font-semibold text-[hsl(var(--foreground))]">
{template.name}
{#if template.isDefault}
<Star size={14} weight="fill" class="ml-1 inline text-yellow-500" />
{/if}
</h3>
{#if template.description}
<p class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))] line-clamp-2">
{template.description}
</p>
{/if}
</div>
</div>
</div>
{#if template.documentMode}
<span
class="mt-2 inline-block rounded bg-[hsl(var(--muted))] px-2 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
>
Dokumentmodus
</span>
{/if}
<div class="mt-4 flex items-center gap-2">
<button
onclick={() => handleUse(template)}
class="flex items-center gap-1.5 rounded-lg bg-[hsl(var(--primary))] px-3 py-1.5 text-xs font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<Play size={14} />
Verwenden
</button>
<button
onclick={() => openEditForm(template)}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<PencilSimple size={16} />
</button>
<button
onclick={() => handleSetDefault(template.id)}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-yellow-500"
title="Als Standard setzen"
>
<Star size={16} weight={template.isDefault ? 'fill' : 'regular'} />
</button>
<button
onclick={() => handleDelete(template.id)}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
>
<Trash size={16} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Form Modal -->
{#if showForm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
class="w-full max-w-lg rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-[hsl(var(--foreground))]">
{editingId ? 'Vorlage bearbeiten' : 'Neue Vorlage'}
</h2>
<button
onclick={() => (showForm = false)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<X size={20} />
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<div>
<label for="tpl-name" class="mb-1 block text-sm font-medium">Name</label>
<input
id="tpl-name"
type="text"
bind:value={formName}
required
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label for="tpl-desc" class="mb-1 block text-sm font-medium">Beschreibung</label>
<input
id="tpl-desc"
type="text"
bind:value={formDescription}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label for="tpl-prompt" class="mb-1 block text-sm font-medium">System-Prompt</label>
<textarea
id="tpl-prompt"
bind:value={formSystemPrompt}
required
rows="4"
class="w-full resize-none rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Farbe</label>
<div class="flex gap-2">
{#each COLORS as color}
<button
type="button"
onclick={() => (formColor = color)}
class="h-7 w-7 rounded-full border-2 transition-transform {formColor === color
? 'scale-110 border-[hsl(var(--foreground))]'
: 'border-transparent hover:scale-105'}"
style="background-color: {color}"
></button>
{/each}
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={formDocumentMode} class="rounded" />
Dokumentmodus
</label>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
onclick={() => (showForm = false)}
class="rounded-lg px-4 py-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!formName.trim() || !formSystemPrompt.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{editingId ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllContacts } from '$lib/modules/contacts/queries';
import { contactsFilterStore } from '$lib/modules/contacts/stores/filter.svelte';
let { children }: { children: Snippet } = $props();
// Live query — auto-update when IndexedDB changes
const allContacts = useAllContacts();
// Provide data to child components via Svelte context
setContext('contacts', allContacts);
// Initialize filter state from localStorage
contactsFilterStore.initialize();
</script>
{@render children()}

View file

@ -0,0 +1,344 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import {
type Contact,
contactsFilterStore,
contactsStore,
contactModalStore,
searchContacts,
filterActive,
filterFavorites,
sortContacts,
applyContactFilter,
groupByLetter,
getDisplayName,
getInitials,
} from '$lib/modules/contacts';
import {
MagnifyingGlass,
Plus,
Star,
StarFill,
Archive,
Trash,
PencilSimple,
Funnel,
Users,
} from '@manacore/shared-icons';
// Get contacts from layout context
const allContacts$: Observable<Contact[]> = getContext('contacts');
let allContacts = $state<Contact[]>([]);
$effect(() => {
const sub = allContacts$.subscribe((contacts) => {
allContacts = contacts;
});
return () => sub.unsubscribe();
});
// Filtered & sorted contacts
let activeContacts = $derived(filterActive(allContacts));
let filtered = $derived(applyContactFilter(activeContacts, contactsFilterStore.contactFilter));
let searched = $derived(searchContacts(filtered, contactsFilterStore.searchQuery));
let sorted = $derived(sortContacts(searched, contactsFilterStore.sortField));
// Stats
let totalCount = $derived(activeContacts.length);
let favoriteCount = $derived(filterFavorites(activeContacts).length);
// Alphabet grouping
let groups = $derived(groupByLetter(sorted, contactsFilterStore.sortField));
let letters = $derived(Object.keys(groups).sort());
// Handlers
function handleToggleFavorite(e: MouseEvent, id: string) {
e.stopPropagation();
contactsStore.toggleFavorite(id);
}
function handleArchive(e: MouseEvent, id: string) {
e.stopPropagation();
contactsStore.toggleArchive(id);
}
function handleDelete(e: MouseEvent, contact: Contact) {
e.stopPropagation();
if (!confirm(`"${getDisplayName(contact)}" endgueltig loeschen?`)) return;
contactsStore.deleteContact(contact.id);
}
let showFilters = $state(false);
</script>
<svelte:head>
<title>Kontakte - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<!-- Header -->
<header class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Kontakte</h1>
<p class="text-muted-foreground mt-1 text-sm">
{totalCount} Kontakte{favoriteCount > 0 ? ` · ${favoriteCount} Favoriten` : ''}
</p>
</div>
<button
onclick={() => contactModalStore.open()}
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus size={16} />
Neu
</button>
</header>
<!-- Search & Filter Bar -->
<div class="mb-4 flex gap-2">
<div class="relative flex-1">
<MagnifyingGlass
size={18}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
placeholder="Kontakte suchen..."
value={contactsFilterStore.searchQuery}
oninput={(e) => 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"
/>
</div>
<button
onclick={() => (showFilters = !showFilters)}
class="flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-2 text-sm transition-colors hover:bg-muted"
class:border-primary={contactsFilterStore.contactFilter !== 'all'}
class:text-primary={contactsFilterStore.contactFilter !== 'all'}
>
<Funnel size={16} />
</button>
</div>
<!-- Filter Options -->
{#if showFilters}
<div class="mb-4 flex flex-wrap gap-2">
{#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}
<button
onclick={() => contactsFilterStore.setContactFilter(filter.value)}
class="rounded-full border px-3 py-1 text-xs font-medium transition-colors
{contactsFilterStore.contactFilter === filter.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-primary/50'}"
>
{filter.label}
</button>
{/each}
</div>
{/if}
<!-- Contact List -->
{#if sorted.length === 0}
<div class="flex flex-col items-center py-12 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Users size={32} class="text-muted-foreground" />
</div>
{#if contactsFilterStore.searchQuery}
<h2 class="mb-1 text-lg font-semibold text-foreground">Keine Ergebnisse</h2>
<p class="text-sm text-muted-foreground">
Keine Kontakte gefunden fuer "{contactsFilterStore.searchQuery}"
</p>
{:else}
<h2 class="mb-1 text-lg font-semibold text-foreground">Noch keine Kontakte</h2>
<p class="mb-4 text-sm text-muted-foreground">
Erstelle deinen ersten Kontakt oder importiere bestehende.
</p>
<div class="flex gap-2">
<button
onclick={() => contactModalStore.open()}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
>
Kontakt erstellen
</button>
<a
href="/contacts/import"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Importieren
</a>
</div>
{/if}
</div>
{:else}
<!-- Alphabet sections -->
{#each letters as letter (letter)}
<div class="mb-4">
<div
class="sticky top-0 z-10 mb-1 bg-background/90 px-1 py-1 text-xs font-bold uppercase tracking-wider text-muted-foreground backdrop-blur-sm"
>
{letter}
</div>
<div class="space-y-1">
{#each groups[letter] as contact (contact.id)}
<a
href="/contacts/{contact.id}"
class="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 transition-colors hover:border-border hover:bg-card group"
>
<!-- Avatar -->
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{#if contact.photoUrl}
<img
src={contact.photoUrl}
alt={getDisplayName(contact)}
class="h-full w-full rounded-full object-cover"
/>
{:else}
{getInitials(contact)}
{/if}
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">
{getDisplayName(contact)}
</span>
{#if contact.isFavorite}
<StarFill size={14} class="flex-shrink-0 text-amber-500" />
{/if}
</div>
{#if contact.company || contact.jobTitle}
<div class="truncate text-xs text-muted-foreground">
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
</div>
{/if}
</div>
<!-- Actions (visible on hover) -->
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onclick={(e) => handleToggleFavorite(e, contact.id)}
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-amber-500"
title={contact.isFavorite ? 'Favorit entfernen' : 'Zu Favoriten'}
>
{#if contact.isFavorite}
<StarFill size={14} />
{:else}
<Star size={14} />
{/if}
</button>
<button
onclick={(e) => handleArchive(e, contact.id)}
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted"
title="Archivieren"
>
<Archive size={14} />
</button>
</div>
</a>
{/each}
</div>
</div>
{/each}
<p class="mt-4 text-center text-xs text-muted-foreground">
{sorted.length} Kontakt{sorted.length !== 1 ? 'e' : ''}
</p>
{/if}
</div>
<!-- New/Edit Contact Modal -->
{#if contactModalStore.isOpen}
{@const isEditing = !!contactModalStore.editContactId}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl">
<h2 class="mb-4 text-lg font-bold text-foreground">
{isEditing ? 'Kontakt bearbeiten' : 'Neuer Kontakt'}
</h2>
<form
onsubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
firstName: (formData.get('firstName') as string) || undefined,
lastName: (formData.get('lastName') as string) || undefined,
email: (formData.get('email') as string) || undefined,
phone: (formData.get('phone') as string) || undefined,
company: (formData.get('company') as string) || undefined,
jobTitle: (formData.get('jobTitle') as string) || undefined,
};
contactsStore.createContact(data);
contactModalStore.close();
}}
class="space-y-3"
>
<div class="grid grid-cols-2 gap-3">
<input
name="firstName"
type="text"
placeholder="Vorname"
value={contactModalStore.prefillData?.firstName ?? ''}
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"
/>
<input
name="lastName"
type="text"
placeholder="Nachname"
value={contactModalStore.prefillData?.lastName ?? ''}
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"
/>
</div>
<input
name="email"
type="email"
placeholder="E-Mail"
value={contactModalStore.prefillData?.email ?? ''}
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"
/>
<input
name="phone"
type="tel"
placeholder="Telefon"
value={contactModalStore.prefillData?.phone ?? ''}
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"
/>
<input
name="company"
type="text"
placeholder="Unternehmen"
value={contactModalStore.prefillData?.company ?? ''}
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"
/>
<input
name="jobTitle"
type="text"
placeholder="Position"
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"
/>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
onclick={() => contactModalStore.close()}
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Abbrechen
</button>
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Speichern
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,371 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import { type Contact, contactsStore, getDisplayName, getInitials } from '$lib/modules/contacts';
import {
CaretLeft,
Star,
StarFill,
Archive,
Trash,
PencilSimple,
Envelope,
Phone,
Buildings,
MapPin,
Cake,
Note,
} from '@manacore/shared-icons';
const allContacts$: Observable<Contact[]> = getContext('contacts');
let allContacts = $state<Contact[]>([]);
$effect(() => {
const sub = allContacts$.subscribe((c) => {
allContacts = c;
});
return () => sub.unsubscribe();
});
let contactId = $derived($page.params.id);
let contact = $derived(allContacts.find((c) => c.id === contactId));
// Editing state
let isEditing = $state(false);
let editData = $state<Partial<Contact>>({});
function startEdit() {
if (!contact) return;
editData = {
firstName: contact.firstName,
lastName: contact.lastName,
email: contact.email,
phone: contact.phone,
company: contact.company,
jobTitle: contact.jobTitle,
notes: contact.notes,
birthday: contact.birthday,
};
isEditing = true;
}
async function saveEdit() {
if (!contact) return;
await contactsStore.updateContact(contact.id, editData);
isEditing = false;
}
function cancelEdit() {
isEditing = false;
editData = {};
}
async function handleToggleFavorite() {
if (!contact) return;
await contactsStore.toggleFavorite(contact.id);
}
async function handleArchive() {
if (!contact) return;
await contactsStore.toggleArchive(contact.id);
goto('/contacts');
}
async function handleDelete() {
if (!contact) return;
if (!confirm(`"${getDisplayName(contact)}" endgueltig loeschen?`)) return;
await contactsStore.deleteContact(contact.id);
goto('/contacts');
}
</script>
<svelte:head>
<title>{contact ? getDisplayName(contact) : 'Kontakt'} - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<!-- Back Link -->
<a
href="/contacts"
class="mb-4 inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<CaretLeft size={16} />
Kontakte
</a>
{#if !contact}
<div class="flex flex-col items-center py-16 text-center">
<h2 class="text-lg font-semibold text-foreground">Kontakt nicht gefunden</h2>
<p class="mt-1 text-sm text-muted-foreground">
Dieser Kontakt existiert nicht oder wurde geloescht.
</p>
<a
href="/contacts"
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
>
Zurueck zu Kontakten
</a>
</div>
{:else}
<!-- Profile Header -->
<div class="mb-6 rounded-xl border border-border bg-card p-6">
<div class="flex items-start gap-4">
<!-- Avatar -->
<div
class="flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-xl font-bold text-primary"
>
{#if contact.photoUrl}
<img
src={contact.photoUrl}
alt={getDisplayName(contact)}
class="h-full w-full rounded-full object-cover"
/>
{:else}
{getInitials(contact)}
{/if}
</div>
<!-- Name & Title -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h1 class="text-xl font-bold text-foreground">{getDisplayName(contact)}</h1>
{#if contact.isFavorite}
<StarFill size={18} class="text-amber-500" />
{/if}
</div>
{#if contact.company || contact.jobTitle}
<p class="mt-0.5 text-sm text-muted-foreground">
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
</p>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-1">
<button
onclick={() => startEdit()}
class="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Bearbeiten"
>
<PencilSimple size={18} />
</button>
<button
onclick={handleToggleFavorite}
class="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-amber-500"
title={contact.isFavorite ? 'Favorit entfernen' : 'Zu Favoriten'}
>
{#if contact.isFavorite}
<StarFill size={18} class="text-amber-500" />
{:else}
<Star size={18} />
{/if}
</button>
<button
onclick={handleArchive}
class="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted"
title="Archivieren"
>
<Archive size={18} />
</button>
<button
onclick={handleDelete}
class="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-red-500"
title="Loeschen"
>
<Trash size={18} />
</button>
</div>
</div>
</div>
<!-- Contact Details -->
{#if isEditing}
<!-- Edit Form -->
<div class="rounded-xl border border-border bg-card p-6">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Bearbeiten
</h2>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Vorname"
value={editData.firstName ?? ''}
oninput={(e) => (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"
/>
<input
type="text"
placeholder="Nachname"
value={editData.lastName ?? ''}
oninput={(e) => (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"
/>
</div>
<input
type="email"
placeholder="E-Mail"
value={editData.email ?? ''}
oninput={(e) => (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"
/>
<input
type="tel"
placeholder="Telefon"
value={editData.phone ?? ''}
oninput={(e) => (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"
/>
<input
type="text"
placeholder="Unternehmen"
value={editData.company ?? ''}
oninput={(e) => (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"
/>
<input
type="text"
placeholder="Position"
value={editData.jobTitle ?? ''}
oninput={(e) => (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"
/>
<input
type="date"
placeholder="Geburtstag"
value={editData.birthday ?? ''}
oninput={(e) => (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"
/>
<textarea
placeholder="Notizen"
value={editData.notes ?? ''}
oninput={(e) => (editData.notes = e.currentTarget.value || null)}
rows="3"
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"
></textarea>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
onclick={cancelEdit}
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Abbrechen
</button>
<button
type="button"
onclick={saveEdit}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Speichern
</button>
</div>
</div>
</div>
{:else}
<!-- Detail Cards -->
<div class="space-y-4">
<!-- Contact Info -->
<div class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Kontaktdaten
</h2>
<div class="space-y-3">
{#if contact.email}
<div class="flex items-center gap-3">
<Envelope size={16} class="flex-shrink-0 text-muted-foreground" />
<a href="mailto:{contact.email}" class="text-sm text-primary hover:underline">
{contact.email}
</a>
</div>
{/if}
{#if contact.phone}
<div class="flex items-center gap-3">
<Phone size={16} class="flex-shrink-0 text-muted-foreground" />
<a href="tel:{contact.phone}" class="text-sm text-primary hover:underline">
{contact.phone}
</a>
</div>
{/if}
{#if contact.company}
<div class="flex items-center gap-3">
<Buildings size={16} class="flex-shrink-0 text-muted-foreground" />
<span class="text-sm text-foreground">{contact.company}</span>
</div>
{/if}
{#if contact.birthday}
<div class="flex items-center gap-3">
<Cake size={16} class="flex-shrink-0 text-muted-foreground" />
<span class="text-sm text-foreground">
{new Date(contact.birthday).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
{/if}
</div>
{#if !contact.email && !contact.phone && !contact.company && !contact.birthday}
<p class="text-sm text-muted-foreground">Keine Kontaktdaten hinterlegt.</p>
{/if}
</div>
<!-- Notes -->
{#if contact.notes}
<div class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Notizen
</h2>
<p class="whitespace-pre-wrap text-sm text-foreground">{contact.notes}</p>
</div>
{/if}
<!-- Tags -->
{#if contact.tags.length > 0}
<div class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Tags
</h2>
<div class="flex flex-wrap gap-2">
{#each contact.tags as tag (tag.id)}
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
{tag.name}
</span>
{/each}
</div>
</div>
{/if}
<!-- Metadata -->
<div class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Details
</h2>
<div class="grid grid-cols-2 gap-y-2 text-xs text-muted-foreground">
<span>Erstellt</span>
<span>
{new Date(contact.createdAt).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>Aktualisiert</span>
<span>
{new Date(contact.updatedAt).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import {
useAllMemos,
useArchivedMemos,
useAllTags,
useAllMemoTags,
} from '$lib/modules/memoro/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes
const allMemos = useAllMemos();
const archivedMemos = useArchivedMemos();
const allTags = useAllTags();
const allMemoTags = useAllMemoTags();
// Provide data to child components via Svelte context
setContext('memos', allMemos);
setContext('archivedMemos', archivedMemos);
setContext('tags', allTags);
setContext('memoTags', allMemoTags);
</script>
{@render children()}

View file

@ -0,0 +1,262 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { memosStore } from '$lib/modules/memoro/stores/memos.svelte';
import {
filterBySearch,
filterByTag,
getTagsForMemo,
formatDuration,
getStatusLabel,
} from '$lib/modules/memoro/queries';
import type { Memo, Tag, LocalMemoTag } from '$lib/modules/memoro/types';
import {
Plus,
MagnifyingGlass,
PushPin,
Archive,
Microphone,
Tag as TagIcon,
} from '@manacore/shared-icons';
const memosCtx: { readonly value: Memo[] } = getContext('memos');
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
const memoTagsCtx: { readonly value: LocalMemoTag[] } = getContext('memoTags');
let searchQuery = $state('');
let selectedTagId = $state<string | null>(null);
let filtered = $derived(() => {
let result = filterBySearch(memosCtx.value, searchQuery);
if (selectedTagId) {
result = filterByTag(result, memoTagsCtx.value, selectedTagId);
}
return result;
});
function handleMemoClick(id: string) {
goto(`/memoro/${id}`);
}
async function handleNewMemo() {
const memo = await memosStore.create({});
goto(`/memoro/${memo.id}`);
}
async function handlePin(e: Event, id: string, isPinned: boolean) {
e.stopPropagation();
if (isPinned) {
await memosStore.unpin(id);
} else {
await memosStore.pin(id);
}
}
async function handleArchive(e: Event, id: string) {
e.stopPropagation();
await memosStore.archive(id);
}
function getMemoTags(memoId: string): Tag[] {
return getTagsForMemo(tagsCtx.value, memoTagsCtx.value, memoId);
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Heute';
if (days === 1) return 'Gestern';
if (days < 7) return `vor ${days} Tagen`;
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
}
</script>
<svelte:head>
<title>Memoro - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Memoro</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{memosCtx.value.length} Memos
</p>
</div>
<div class="flex items-center gap-2">
<a
href="/memoro/tags"
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm font-medium text-[hsl(var(--foreground))] transition-colors hover:bg-[hsl(var(--muted))]"
>
<TagIcon size={16} />
Tags
</a>
<button
onclick={handleNewMemo}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
<Plus size={20} />
Neues Memo
</button>
</div>
</div>
<!-- Search -->
<div class="relative">
<MagnifyingGlass
size={18}
class="absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
/>
<input
type="text"
placeholder="Memos durchsuchen..."
bind:value={searchQuery}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] py-2.5 pl-10 pr-4 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Tag Filter -->
{#if tagsCtx.value.length > 0}
<div class="flex flex-wrap gap-2">
<button
onclick={() => (selectedTagId = null)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {selectedTagId === null
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted)/0.8)]'}"
>
Alle
</button>
{#each tagsCtx.value as tag (tag.id)}
<button
onclick={() => (selectedTagId = selectedTagId === tag.id ? null : tag.id)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {selectedTagId ===
tag.id
? 'text-white'
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted)/0.8)]'}"
style={selectedTagId === tag.id && tag.color ? `background-color: ${tag.color}` : ''}
>
{tag.name}
</button>
{/each}
</div>
{/if}
<!-- Memos List -->
{#if memosCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[hsl(var(--primary)/0.1)]"
>
<Microphone size={32} weight="duotone" class="text-[hsl(var(--primary))]" />
</div>
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">
Erstelle dein erstes Memo
</h2>
<p class="mb-6 text-sm text-[hsl(var(--muted-foreground))]">
Nimm Gedanken auf oder schreibe sie direkt auf.
</p>
<button
onclick={handleNewMemo}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
Neues Memo
</button>
</div>
{:else}
<div class="space-y-2">
{#each filtered() as memo (memo.id)}
<div
role="button"
tabindex="0"
onclick={() => 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)]"
>
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{#if memo.isPinned}
<PushPin size={14} weight="fill" class="shrink-0 text-[hsl(var(--primary))]" />
{/if}
<h3 class="truncate font-semibold text-[hsl(var(--foreground))]">
{memo.title || 'Unbenanntes Memo'}
</h3>
</div>
{#if memo.intro}
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))] line-clamp-2">
{memo.intro}
</p>
{:else if memo.transcript}
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))] line-clamp-2">
{memo.transcript}
</p>
{/if}
</div>
<div
class="ml-4 flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={(e) => handlePin(e, memo.id, memo.isPinned)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
title={memo.isPinned ? 'Loslosen' : 'Anpinnen'}
>
<PushPin size={16} weight={memo.isPinned ? 'fill' : 'regular'} />
</button>
<button
onclick={(e) => handleArchive(e, memo.id)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
title="Archivieren"
>
<Archive size={16} />
</button>
</div>
</div>
<!-- Footer -->
<div class="mt-3 flex items-center gap-3">
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{formatDate(memo.createdAt)}
</span>
{#if memo.audioDurationMs}
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{formatDuration(memo.audioDurationMs)}
</span>
{/if}
{#if memo.processingStatus !== 'completed'}
<span
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
>
{getStatusLabel(memo.processingStatus)}
</span>
{/if}
<!-- Tags -->
{#each getMemoTags(memo.id) as tag (tag.id)}
<span
class="rounded-full px-2 py-0.5 text-[10px] font-medium text-white"
style="background-color: {tag.color || 'hsl(var(--muted))'}"
>
{tag.name}
</span>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<!-- Archive link -->
<div class="pt-2">
<a
href="/memoro/archive"
class="inline-flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<Archive size={16} />
Archiv anzeigen
</a>
</div>
</div>

View file

@ -0,0 +1,289 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { memosStore } from '$lib/modules/memoro/stores/memos.svelte';
import { memoriesStore } from '$lib/modules/memoro/stores/memories.svelte';
import { tagsStore } from '$lib/modules/memoro/stores/tags.svelte';
import {
useMemoriesByMemo,
getTagsForMemo,
formatDuration,
getStatusLabel,
} from '$lib/modules/memoro/queries';
import type { Memo, Memory, Tag, LocalMemoTag } from '$lib/modules/memoro/types';
import {
ArrowLeft,
Trash,
PushPin,
Archive,
PencilSimple,
Check,
X,
Plus,
Tag as TagIcon,
} from '@manacore/shared-icons';
const memosCtx: { readonly value: Memo[] } = getContext('memos');
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
const memoTagsCtx: { readonly value: LocalMemoTag[] } = getContext('memoTags');
const memoId = $derived($page.params.id ?? '');
const memo = $derived(memosCtx.value.find((m) => m.id === memoId));
// Live query for memories of this memo
const memoriesQuery = $derived(useMemoriesByMemo(memoId));
let memories = $derived((memoriesQuery as { value: Memory[] })?.value ?? []);
let memoTags = $derived(getTagsForMemo(tagsCtx.value, memoTagsCtx.value, memoId));
let isEditingTitle = $state(false);
let editTitle = $state('');
let showTagPicker = $state(false);
function startEditTitle() {
editTitle = memo?.title ?? '';
isEditingTitle = true;
}
async function saveTitle() {
if (editTitle.trim()) {
await memosStore.update(memoId, { title: editTitle.trim() });
}
isEditingTitle = false;
}
async function togglePin() {
if (!memo) return;
if (memo.isPinned) {
await memosStore.unpin(memoId);
} else {
await memosStore.pin(memoId);
}
}
async function handleArchive() {
await memosStore.archive(memoId);
goto('/memoro');
}
async function handleDelete() {
if (confirm('Memo wirklich loschen?')) {
await memosStore.delete(memoId);
goto('/memoro');
}
}
async function handleAddTag(tagId: string) {
await tagsStore.addToMemo(memoId, tagId);
showTagPicker = false;
}
async function handleRemoveTag(tagId: string) {
await tagsStore.removeFromMemo(memoId, tagId);
}
// Available tags (not already assigned)
let availableTags = $derived(tagsCtx.value.filter((t) => !memoTags.some((mt) => mt.id === t.id)));
</script>
<svelte:head>
<title>{memo?.title || 'Memo'} - Memoro - ManaCore</title>
</svelte:head>
{#if !memo}
<div class="flex flex-col items-center justify-center py-16">
<p class="mb-4 text-[hsl(var(--muted-foreground))]">Memo nicht gefunden</p>
<a href="/memoro" class="text-sm text-[hsl(var(--primary))] hover:underline">
Zuruck zu Memoro
</a>
</div>
{:else}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/memoro"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<div class="min-w-0 flex-1">
{#if isEditingTitle}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={editTitle}
onkeydown={(e) => 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))]"
/>
<button onclick={saveTitle} class="text-[hsl(var(--primary))]">
<Check size={18} />
</button>
<button
onclick={() => (isEditingTitle = false)}
class="text-[hsl(var(--muted-foreground))]"
>
<X size={18} />
</button>
</div>
{:else}
<button onclick={startEditTitle} class="group flex items-center gap-2 text-left">
<h1 class="text-xl font-bold text-[hsl(var(--foreground))]">
{memo.title || 'Unbenanntes Memo'}
</h1>
<PencilSimple
size={16}
class="shrink-0 text-[hsl(var(--muted-foreground))] opacity-0 group-hover:opacity-100"
/>
</button>
{/if}
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{new Date(memo.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
{#if memo.audioDurationMs}
&middot; {formatDuration(memo.audioDurationMs)}
{/if}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<button
onclick={togglePin}
class="rounded-lg p-1.5 transition-colors {memo.isPinned
? 'text-[hsl(var(--primary))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'}"
title={memo.isPinned ? 'Loslosen' : 'Anpinnen'}
>
<PushPin size={18} weight={memo.isPinned ? 'fill' : 'regular'} />
</button>
<button
onclick={handleArchive}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
title="Archivieren"
>
<Archive size={18} />
</button>
<button
onclick={handleDelete}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title="Loschen"
>
<Trash size={18} />
</button>
</div>
</div>
<!-- Status -->
{#if memo.processingStatus !== 'completed'}
<div
class="rounded-lg bg-[hsl(var(--muted))] px-4 py-2 text-sm text-[hsl(var(--muted-foreground))]"
>
Status: {getStatusLabel(memo.processingStatus)}
</div>
{/if}
<!-- Tags -->
<div class="flex flex-wrap items-center gap-2">
{#each memoTags as tag (tag.id)}
<span
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium text-white"
style="background-color: {tag.color || 'hsl(var(--muted))'}"
>
{tag.name}
<button
onclick={() => handleRemoveTag(tag.id)}
class="ml-0.5 rounded-full hover:bg-white/20"
>
<X size={12} />
</button>
</span>
{/each}
<div class="relative">
<button
onclick={() => (showTagPicker = !showTagPicker)}
class="flex items-center gap-1 rounded-full border border-dashed border-[hsl(var(--border))] px-2.5 py-1 text-xs text-[hsl(var(--muted-foreground))] hover:border-[hsl(var(--primary))] hover:text-[hsl(var(--primary))]"
>
<TagIcon size={12} />
Tag hinzufugen
</button>
{#if showTagPicker && availableTags.length > 0}
<div
class="absolute left-0 top-full z-10 mt-1 w-48 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-2 shadow-lg"
>
{#each availableTags as tag (tag.id)}
<button
onclick={() => handleAddTag(tag.id)}
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-[hsl(var(--muted))]"
>
<span class="h-3 w-3 rounded-full" style="background-color: {tag.color || '#888'}"
></span>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Intro -->
{#if memo.intro}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5">
<h2
class="mb-2 text-sm font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]"
>
Zusammenfassung
</h2>
<p class="text-[hsl(var(--foreground))]">{memo.intro}</p>
</div>
{/if}
<!-- Transcript -->
{#if memo.transcript}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5">
<h2
class="mb-2 text-sm font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]"
>
Transkript
</h2>
<p class="whitespace-pre-wrap text-sm leading-relaxed text-[hsl(var(--foreground))]">
{memo.transcript}
</p>
</div>
{/if}
<!-- Memories -->
<div>
<h2
class="mb-3 text-sm font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]"
>
Erinnerungen ({memories.length})
</h2>
{#if memories.length === 0}
<p class="text-sm text-[hsl(var(--muted-foreground))]">
Noch keine Erinnerungen fur dieses Memo.
</p>
{:else}
<div class="space-y-3">
{#each memories as memory (memory.id)}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="font-medium text-[hsl(var(--foreground))]">{memory.title}</h3>
{#if memory.content}
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">
{memory.content}
</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,108 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { memosStore } from '$lib/modules/memoro/stores/memos.svelte';
import type { Memo } from '$lib/modules/memoro/types';
import { ArrowLeft, ArrowCounterClockwise, Trash, Microphone } from '@manacore/shared-icons';
const archivedCtx: { readonly value: Memo[] } = getContext('archivedMemos');
async function handleUnarchive(e: Event, id: string) {
e.stopPropagation();
await memosStore.unarchive(id);
}
async function handleDelete(e: Event, id: string) {
e.stopPropagation();
if (confirm('Memo endgultig loschen?')) {
await memosStore.delete(id);
}
}
function handleClick(id: string) {
goto(`/memoro/${id}`);
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>Archiv - Memoro - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<a
href="/memoro"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Archiv</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{archivedCtx.value.length} archivierte Memos
</p>
</div>
</div>
{#if archivedCtx.value.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<Microphone size={48} class="mb-4 text-[hsl(var(--muted-foreground))]" />
<p class="text-[hsl(var(--muted-foreground))]">Keine archivierten Memos</p>
</div>
{:else}
<div class="space-y-2">
{#each archivedCtx.value as memo (memo.id)}
<div
role="button"
tabindex="0"
onclick={() => 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)]"
>
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<h3 class="truncate font-medium text-[hsl(var(--foreground))]">
{memo.title || 'Unbenanntes Memo'}
</h3>
{#if memo.intro}
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))] line-clamp-1">
{memo.intro}
</p>
{/if}
<p class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
{formatDate(memo.updatedAt)}
</p>
</div>
<div
class="ml-4 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={(e) => handleUnarchive(e, memo.id)}
class="rounded p-1.5 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--primary))]"
title="Wiederherstellen"
>
<ArrowCounterClockwise size={16} />
</button>
<button
onclick={(e) => handleDelete(e, memo.id)}
class="rounded p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title="Endgultig loschen"
>
<Trash size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,224 @@
<script lang="ts">
import { getContext } from 'svelte';
import { tagsStore } from '$lib/modules/memoro/stores/tags.svelte';
import type { Tag } from '$lib/modules/memoro/types';
import {
ArrowLeft,
Plus,
Trash,
PencilSimple,
PushPin,
Check,
X,
Tag as TagIcon,
} from '@manacore/shared-icons';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
let showCreateForm = $state(false);
let editingId = $state<string | null>(null);
let formName = $state('');
let formColor = $state('#3b82f6');
const COLORS = [
'#3b82f6',
'#8b5cf6',
'#ec4899',
'#f97316',
'#10b981',
'#06b6d4',
'#ef4444',
'#eab308',
];
function openCreateForm() {
editingId = null;
formName = '';
formColor = '#3b82f6';
showCreateForm = true;
}
function openEditForm(tag: Tag) {
editingId = tag.id;
formName = tag.name;
formColor = tag.color || '#3b82f6';
showCreateForm = true;
}
async function handleSubmit() {
if (!formName.trim()) return;
if (editingId) {
await tagsStore.update(editingId, { name: formName.trim(), color: formColor });
} else {
await tagsStore.create({ name: formName.trim(), color: formColor });
}
showCreateForm = false;
}
async function handleDelete(id: string) {
if (confirm('Tag wirklich loschen?')) {
await tagsStore.delete(id);
}
}
async function handleTogglePin(tag: Tag) {
await tagsStore.update(tag.id, { isPinned: !tag.isPinned });
}
</script>
<svelte:head>
<title>Tags - Memoro - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/memoro"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Tags</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{tagsCtx.value.length} Tags
</p>
</div>
</div>
<button
onclick={openCreateForm}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<Plus size={16} />
Neuer Tag
</button>
</div>
{#if tagsCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<TagIcon size={48} class="mb-4 text-[hsl(var(--muted-foreground))]" />
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">Keine Tags</h2>
<p class="mb-6 text-sm text-[hsl(var(--muted-foreground))]">
Erstelle Tags, um deine Memos zu organisieren.
</p>
<button
onclick={openCreateForm}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
Neuer Tag
</button>
</div>
{:else}
<div class="space-y-2">
{#each tagsCtx.value as tag (tag.id)}
<div
class="group flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4"
>
<span
class="h-4 w-4 shrink-0 rounded-full"
style="background-color: {tag.color || '#888'}"
></span>
<span class="flex-1 font-medium text-[hsl(var(--foreground))]">{tag.name}</span>
{#if tag.isPinned}
<PushPin size={14} weight="fill" class="text-[hsl(var(--primary))]" />
{/if}
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onclick={() => handleTogglePin(tag)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--primary))]"
title={tag.isPinned ? 'Loslosen' : 'Anpinnen'}
>
<PushPin size={16} weight={tag.isPinned ? 'fill' : 'regular'} />
</button>
<button
onclick={() => openEditForm(tag)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<PencilSimple size={16} />
</button>
<button
onclick={() => handleDelete(tag.id)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:text-red-500"
>
<Trash size={16} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create/Edit Form Modal -->
{#if showCreateForm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
class="w-full max-w-sm rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-[hsl(var(--foreground))]">
{editingId ? 'Tag bearbeiten' : 'Neuer Tag'}
</h2>
<button
onclick={() => (showCreateForm = false)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<X size={20} />
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<div>
<label for="tag-name" class="mb-1 block text-sm font-medium">Name</label>
<input
id="tag-name"
type="text"
bind:value={formName}
required
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Farbe</label>
<div class="flex gap-2">
{#each COLORS as color}
<button
type="button"
onclick={() => (formColor = color)}
class="h-7 w-7 rounded-full border-2 transition-transform {formColor === color
? 'scale-110 border-[hsl(var(--foreground))]'
: 'border-transparent hover:scale-105'}"
style="background-color: {color}"
></button>
{/each}
</div>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
onclick={() => (showCreateForm = false)}
class="px-4 py-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!formName.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{editingId ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import {
useAllSongs,
useAllPlaylists,
useAllPlaylistSongs,
useAllProjects,
} from '$lib/modules/mukke/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes
const allSongs = useAllSongs();
const allPlaylists = useAllPlaylists();
const allPlaylistSongs = useAllPlaylistSongs();
const allProjects = useAllProjects();
// Provide data to child components via Svelte context
setContext('songs', allSongs);
setContext('playlists', allPlaylists);
setContext('playlistSongs', allPlaylistSongs);
setContext('projects', allProjects);
</script>
{@render children()}

View file

@ -0,0 +1,125 @@
<script lang="ts">
import { getContext } from 'svelte';
import { computeStats } from '$lib/modules/mukke/queries';
import type { Song, Playlist, Project } from '$lib/modules/mukke/types';
import { MusicNote, Plus, Playlist as PlaylistIcon, Note } from '@manacore/shared-icons';
const songsCtx: { readonly value: Song[] } = getContext('songs');
const playlistsCtx: { readonly value: Playlist[] } = getContext('playlists');
const projectsCtx: { readonly value: Project[] } = getContext('projects');
let stats = $derived(computeStats(songsCtx.value));
function formatDate(date: string): string {
return new Date(date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>Mukke - ManaCore</title>
</svelte:head>
<div class="space-y-8">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Mukke</h1>
<!-- Quick Stats -->
<section>
<h2
class="mb-4 text-sm font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]"
>
Bibliothek
</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-sm text-[hsl(var(--muted-foreground))]">Songs</p>
<p class="text-2xl font-bold text-[hsl(var(--foreground))]">{stats.totalSongs}</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-sm text-[hsl(var(--muted-foreground))]">Alben</p>
<p class="text-2xl font-bold text-[hsl(var(--foreground))]">{stats.totalAlbums}</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-sm text-[hsl(var(--muted-foreground))]">Kunstler</p>
<p class="text-2xl font-bold text-[hsl(var(--foreground))]">{stats.totalArtists}</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-sm text-[hsl(var(--muted-foreground))]">Genres</p>
<p class="text-2xl font-bold text-[hsl(var(--foreground))]">{stats.totalGenres}</p>
</div>
</div>
</section>
<!-- Quick Actions -->
<section>
<h2
class="mb-4 text-sm font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]"
>
Schnellzugriff
</h2>
<div class="flex flex-wrap gap-3">
<a
href="/mukke/library"
class="inline-flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<MusicNote size={20} />
Bibliothek
</a>
<a
href="/mukke/playlists"
class="inline-flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
<PlaylistIcon size={20} />
Playlists
</a>
<a
href="/mukke/projects"
class="inline-flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
<Plus size={20} />
Projekte
</a>
</div>
</section>
<!-- Recent Projects -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]">
Letzte Projekte
</h2>
<a href="/mukke/projects" class="text-sm text-[hsl(var(--primary))] hover:underline">
Alle anzeigen
</a>
</div>
{#if projectsCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-12"
>
<Note size={40} class="mb-3 text-[hsl(var(--muted-foreground))]" />
<p class="text-sm text-[hsl(var(--muted-foreground))]">Noch keine Projekte</p>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each projectsCtx.value.slice(0, 6) as project (project.id)}
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<h3 class="font-medium text-[hsl(var(--foreground))]">{project.title}</h3>
{#if project.description}
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))] line-clamp-2">
{project.description}
</p>
{/if}
<p class="mt-2 text-xs text-[hsl(var(--muted-foreground))]">
Aktualisiert {formatDate(project.updatedAt)}
</p>
</div>
{/each}
</div>
{/if}
</section>
</div>

View file

@ -0,0 +1,242 @@
<script lang="ts">
import { getContext } from 'svelte';
import { libraryStore } from '$lib/modules/mukke/stores/library.svelte';
import { playerStore } from '$lib/modules/mukke/stores/player.svelte';
import {
searchSongs,
filterFavorites,
groupByAlbum,
groupByGenre,
formatDuration,
} from '$lib/modules/mukke/queries';
import type { Song } from '$lib/modules/mukke/types';
import {
MusicNote,
Heart,
Play,
Pause,
Trash,
MagnifyingGlass,
ArrowLeft,
} from '@manacore/shared-icons';
const songsCtx: { readonly value: Song[] } = getContext('songs');
const tabs = ['songs', 'albums', 'genres'] as const;
type Tab = (typeof tabs)[number];
let activeTab = $state<Tab>('songs');
let searchQuery = $state('');
let filteredSongs = $derived(searchSongs(songsCtx.value, searchQuery));
let albums = $derived(groupByAlbum(songsCtx.value));
let genres = $derived(groupByGenre(songsCtx.value));
function handlePlaySong(song: Song, index: number) {
playerStore.playSong(song, filteredSongs, index);
}
async function handleToggleFavorite(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
await libraryStore.toggleFavorite(id);
}
async function handleDelete(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
const song = songsCtx.value.find((s) => s.id === id);
if (confirm(`"${song?.title}" wirklich loschen?`)) {
await libraryStore.delete(id);
}
}
</script>
<svelte:head>
<title>Bibliothek - Mukke - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<a
href="/mukke"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Bibliothek</h1>
</div>
<!-- Tab Bar -->
<div class="flex max-w-md rounded-lg bg-[hsl(var(--muted))] p-1">
{#each tabs as tab}
<button
onclick={() => (activeTab = tab)}
class="flex-1 rounded-md px-4 py-2 text-sm font-medium capitalize transition-colors {activeTab ===
tab
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'}"
>
{tab === 'songs' ? 'Songs' : tab === 'albums' ? 'Alben' : 'Genres'}
</button>
{/each}
</div>
<!-- Search (songs tab only) -->
{#if activeTab === 'songs'}
<div class="relative">
<MagnifyingGlass
size={18}
class="absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
/>
<input
type="text"
placeholder="Songs durchsuchen..."
bind:value={searchQuery}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] py-2.5 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
{/if}
<!-- Songs Tab -->
{#if activeTab === 'songs'}
{#if filteredSongs.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<MusicNote size={48} class="mb-3 text-[hsl(var(--muted-foreground))]" />
<p class="text-[hsl(var(--muted-foreground))]">
{searchQuery ? 'Keine Songs gefunden' : 'Noch keine Songs in deiner Bibliothek'}
</p>
</div>
{:else}
<div
class="overflow-hidden rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))]"
>
<!-- Header -->
<div
class="grid grid-cols-[40px_1fr_1fr_80px_40px_40px] gap-4 border-b border-[hsl(var(--border))] px-4 py-3 text-xs font-medium uppercase tracking-wide text-[hsl(var(--muted-foreground))]"
>
<span></span>
<span>Titel</span>
<span>Kunstler</span>
<span class="text-right">Dauer</span>
<span></span>
<span></span>
</div>
<!-- Song rows -->
{#each filteredSongs as song, index (song.id)}
<div
role="button"
tabindex="0"
onclick={() => 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)]'
: ''}"
>
<div
class="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded bg-[hsl(var(--muted))]"
>
<MusicNote size={20} class="text-[hsl(var(--muted-foreground))]" />
{#if playerStore.currentSong?.id === song.id && playerStore.isPlaying}
<div class="absolute inset-0 flex items-center justify-center rounded bg-black/40">
<Pause size={20} weight="fill" class="text-white" />
</div>
{:else}
<div
class="absolute inset-0 hidden items-center justify-center rounded bg-black/40 group-hover:flex"
>
<Play size={20} weight="fill" class="text-white" />
</div>
{/if}
</div>
<span
class="truncate font-medium {playerStore.currentSong?.id === song.id
? 'text-[hsl(var(--primary))]'
: 'text-[hsl(var(--foreground))]'}"
>
{song.title}
</span>
<span class="truncate text-[hsl(var(--muted-foreground))]">
{song.artist ?? 'Unbekannt'}
</span>
<span class="text-right text-sm text-[hsl(var(--muted-foreground))]">
{formatDuration(song.duration)}
</span>
<button
onclick={(e) => handleToggleFavorite(song.id, e)}
class="transition-colors {song.favorite
? 'text-red-500'
: 'text-[hsl(var(--muted-foreground))] hover:text-red-500'}"
>
<Heart size={16} weight={song.favorite ? 'fill' : 'regular'} />
</button>
<button
onclick={(e) => handleDelete(song.id, e)}
class="text-[hsl(var(--muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</div>
{/each}
</div>
{/if}
{/if}
<!-- Albums Tab -->
{#if activeTab === 'albums'}
{#if albums.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<p class="text-[hsl(var(--muted-foreground))]">Keine Alben gefunden</p>
</div>
{:else}
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{#each albums as album}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div
class="mb-3 flex aspect-square items-center justify-center rounded-lg bg-[hsl(var(--muted))]"
>
<MusicNote size={48} class="text-[hsl(var(--muted-foreground))]" />
</div>
<h3 class="truncate font-medium text-[hsl(var(--foreground))]">{album.album}</h3>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{album.songCount}
{album.songCount === 1 ? 'Song' : 'Songs'}
</p>
</div>
{/each}
</div>
{/if}
{/if}
<!-- Genres Tab -->
{#if activeTab === 'genres'}
{#if genres.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<p class="text-[hsl(var(--muted-foreground))]">Keine Genres gefunden</p>
</div>
{:else}
<div
class="overflow-hidden rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))]"
>
{#each genres as genre}
<div
class="flex items-center justify-between border-b border-[hsl(var(--border))] px-4 py-3 last:border-b-0"
>
<span class="font-medium text-[hsl(var(--foreground))]">{genre.genre}</span>
<span class="text-sm text-[hsl(var(--muted-foreground))]">
{genre.songCount}
{genre.songCount === 1 ? 'Song' : 'Songs'}
</span>
</div>
{/each}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,177 @@
<script lang="ts">
import { getContext } from 'svelte';
import { playlistsStore } from '$lib/modules/mukke/stores/playlists.svelte';
import type { Playlist } from '$lib/modules/mukke/types';
import {
ArrowLeft,
Plus,
Trash,
MusicNote,
Playlist as PlaylistIcon,
X,
} from '@manacore/shared-icons';
const playlistsCtx: { readonly value: Playlist[] } = getContext('playlists');
let showCreateModal = $state(false);
let newName = $state('');
let newDescription = $state('');
let isCreating = $state(false);
async function handleCreate() {
if (!newName.trim()) return;
isCreating = true;
try {
await playlistsStore.create(newName.trim(), newDescription.trim() || undefined);
newName = '';
newDescription = '';
showCreateModal = false;
} catch (e) {
console.error('Failed to create playlist:', e);
}
isCreating = false;
}
async function handleDelete(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
if (!confirm('Playlist wirklich loschen?')) return;
await playlistsStore.delete(id);
}
</script>
<svelte:head>
<title>Playlists - Mukke - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/mukke"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Playlists</h1>
</div>
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<Plus size={16} />
Neue Playlist
</button>
</div>
{#if playlistsCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<PlaylistIcon size={48} class="mb-3 text-[hsl(var(--muted-foreground))]" />
<p class="mb-3 text-[hsl(var(--muted-foreground))]">Noch keine Playlists</p>
<button
onclick={() => (showCreateModal = true)}
class="text-sm text-[hsl(var(--primary))] hover:underline"
>
Erste Playlist erstellen
</button>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each playlistsCtx.value as playlist (playlist.id)}
<a
href="/mukke/playlists/{playlist.id}"
class="group relative rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div
class="mb-3 flex aspect-square items-center justify-center overflow-hidden rounded-lg bg-[hsl(var(--muted))]"
>
<MusicNote size={48} class="text-[hsl(var(--muted-foreground))]" />
</div>
<h3
class="truncate font-medium text-[hsl(var(--foreground))] group-hover:text-[hsl(var(--primary))]"
>
{playlist.name}
</h3>
{#if playlist.description}
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))] line-clamp-1">
{playlist.description}
</p>
{/if}
<button
onclick={(e) => handleDelete(playlist.id, e)}
class="absolute right-3 top-3 rounded-lg bg-[hsl(var(--card)/0.8)] p-1.5 text-[hsl(var(--muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create Playlist Modal -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
class="w-full max-w-md rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-[hsl(var(--foreground))]">Neue Playlist</h2>
<button
onclick={() => (showCreateModal = false)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<X size={20} />
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="mb-4">
<label for="pl-name" class="mb-1 block text-sm font-medium">Name</label>
<input
id="pl-name"
type="text"
bind:value={newName}
placeholder="Meine Playlist"
required
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div class="mb-6">
<label for="pl-desc" class="mb-1 block text-sm font-medium">Beschreibung (optional)</label
>
<textarea
id="pl-desc"
bind:value={newDescription}
placeholder="Beschreibe deine Playlist..."
rows="3"
class="w-full resize-none rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (showCreateModal = false)}
class="px-4 py-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!newName.trim() || isCreating}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{isCreating ? 'Erstellen...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,204 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { playlistsStore } from '$lib/modules/mukke/stores/playlists.svelte';
import { playerStore } from '$lib/modules/mukke/stores/player.svelte';
import { getPlaylistSongs, formatDuration } from '$lib/modules/mukke/queries';
import type { Song, Playlist, LocalPlaylistSong } from '$lib/modules/mukke/types';
import {
ArrowLeft,
Play,
Pause,
Trash,
MusicNote,
PencilSimple,
Check,
X,
} from '@manacore/shared-icons';
const songsCtx: { readonly value: Song[] } = getContext('songs');
const playlistsCtx: { readonly value: Playlist[] } = getContext('playlists');
const playlistSongsCtx: { readonly value: LocalPlaylistSong[] } = getContext('playlistSongs');
const playlistId = $derived($page.params.id ?? '');
const playlist = $derived(playlistsCtx.value.find((p) => p.id === playlistId));
const songs = $derived(getPlaylistSongs(songsCtx.value, playlistSongsCtx.value, playlistId));
let isEditingName = $state(false);
let editName = $state('');
function startEdit() {
editName = playlist?.name ?? '';
isEditingName = true;
}
async function saveName() {
if (editName.trim()) {
await playlistsStore.update(playlistId, { name: editName.trim() });
}
isEditingName = false;
}
function handlePlaySong(song: Song, index: number) {
playerStore.playSong(song, songs, index);
}
function handlePlayAll() {
if (songs.length > 0) {
playerStore.playSong(songs[0], songs, 0);
}
}
async function handleRemoveSong(songId: string, e: Event) {
e.stopPropagation();
await playlistsStore.removeSong(playlistId, songId);
}
async function handleDeletePlaylist() {
if (confirm('Playlist wirklich loschen?')) {
await playlistsStore.delete(playlistId);
goto('/mukke/playlists');
}
}
</script>
<svelte:head>
<title>{playlist?.name || 'Playlist'} - Mukke - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/mukke/playlists"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<div>
{#if isEditingName}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={editName}
onkeydown={(e) => 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))]"
/>
<button onclick={saveName} class="text-[hsl(var(--primary))]">
<Check size={18} />
</button>
<button
onclick={() => (isEditingName = false)}
class="text-[hsl(var(--muted-foreground))]"
>
<X size={18} />
</button>
</div>
{:else}
<button onclick={startEdit} class="group flex items-center gap-2">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">
{playlist?.name || 'Playlist'}
</h1>
<PencilSimple
size={16}
class="text-[hsl(var(--muted-foreground))] opacity-0 group-hover:opacity-100"
/>
</button>
{/if}
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{songs.length}
{songs.length === 1 ? 'Song' : 'Songs'}
</p>
</div>
</div>
<div class="flex items-center gap-2">
{#if songs.length > 0}
<button
onclick={handlePlayAll}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<Play size={16} weight="fill" />
Alle abspielen
</button>
{/if}
<button
onclick={handleDeletePlaylist}
class="rounded-lg p-2 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title="Playlist loschen"
>
<Trash size={20} />
</button>
</div>
</div>
<!-- Songs -->
{#if songs.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<MusicNote size={48} class="mb-3 text-[hsl(var(--muted-foreground))]" />
<p class="text-[hsl(var(--muted-foreground))]">Keine Songs in dieser Playlist</p>
</div>
{:else}
<div
class="overflow-hidden rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))]"
>
{#each songs as song, index (song.id)}
<div
role="button"
tabindex="0"
onclick={() => 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)]'
: ''}"
>
<div
class="relative flex h-10 w-10 shrink-0 items-center justify-center rounded bg-[hsl(var(--muted))]"
>
<MusicNote size={20} class="text-[hsl(var(--muted-foreground))]" />
{#if playerStore.currentSong?.id === song.id && playerStore.isPlaying}
<div class="absolute inset-0 flex items-center justify-center rounded bg-black/40">
<Pause size={20} weight="fill" class="text-white" />
</div>
{:else}
<div
class="absolute inset-0 hidden items-center justify-center rounded bg-black/40 group-hover:flex"
>
<Play size={20} weight="fill" class="text-white" />
</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<p
class="truncate font-medium {playerStore.currentSong?.id === song.id
? 'text-[hsl(var(--primary))]'
: 'text-[hsl(var(--foreground))]'}"
>
{song.title}
</p>
<p class="truncate text-sm text-[hsl(var(--muted-foreground))]">
{song.artist ?? 'Unbekannt'}
</p>
</div>
<span class="text-sm text-[hsl(var(--muted-foreground))]">
{formatDuration(song.duration)}
</span>
<button
onclick={(e) => handleRemoveSong(song.id, e)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
title="Aus Playlist entfernen"
>
<Trash size={16} />
</button>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,177 @@
<script lang="ts">
import { getContext } from 'svelte';
import { projectsStore } from '$lib/modules/mukke/stores/projects.svelte';
import type { Project } from '$lib/modules/mukke/types';
import { ArrowLeft, Plus, Trash, Note, X } from '@manacore/shared-icons';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
let showCreateModal = $state(false);
let newTitle = $state('');
let newDescription = $state('');
let isCreating = $state(false);
async function handleCreate() {
if (!newTitle.trim()) return;
isCreating = true;
try {
await projectsStore.create({
title: newTitle.trim(),
description: newDescription.trim() || undefined,
});
newTitle = '';
newDescription = '';
showCreateModal = false;
} catch (e) {
console.error('Failed to create project:', e);
}
isCreating = false;
}
async function handleDelete(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
if (!confirm('Projekt wirklich loschen?')) return;
await projectsStore.delete(id);
}
function formatDate(date: string): string {
return new Date(date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>Projekte - Mukke - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/mukke"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowLeft size={20} />
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Projekte</h1>
</div>
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<Plus size={16} />
Neues Projekt
</button>
</div>
{#if projectsCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<Note size={48} class="mb-3 text-[hsl(var(--muted-foreground))]" />
<p class="mb-3 text-[hsl(var(--muted-foreground))]">Noch keine Projekte</p>
<button
onclick={() => (showCreateModal = true)}
class="text-sm text-[hsl(var(--primary))] hover:underline"
>
Erstes Projekt erstellen
</button>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each projectsCtx.value as project (project.id)}
<div
class="group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-start justify-between">
<h3 class="font-medium text-[hsl(var(--foreground))]">{project.title}</h3>
<button
onclick={(e) => handleDelete(project.id, e)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</div>
{#if project.description}
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))] line-clamp-2">
{project.description}
</p>
{/if}
<p class="mt-2 text-xs text-[hsl(var(--muted-foreground))]">
Aktualisiert {formatDate(project.updatedAt)}
</p>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Project Modal -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
class="w-full max-w-md rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-[hsl(var(--foreground))]">Neues Projekt</h2>
<button
onclick={() => (showCreateModal = false)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<X size={20} />
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="mb-4">
<label for="proj-title" class="mb-1 block text-sm font-medium">Titel</label>
<input
id="proj-title"
type="text"
bind:value={newTitle}
placeholder="Mein Projekt"
required
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div class="mb-6">
<label for="proj-desc" class="mb-1 block text-sm font-medium"
>Beschreibung (optional)</label
>
<textarea
id="proj-desc"
bind:value={newDescription}
placeholder="Beschreibe dein Projekt..."
rows="3"
class="w-full resize-none rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (showCreateModal = false)}
class="px-4 py-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!newTitle.trim() || isCreating}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{isCreating ? 'Erstellen...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import {
useAllImages,
useArchivedImages,
useAllBoards,
useAllPictureTags,
useAllImageTags,
} from '$lib/modules/picture/queries';
import { pictureViewStore } from '$lib/modules/picture/stores/view.svelte';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allImages = useAllImages();
const archivedImages = useArchivedImages();
const allBoards = useAllBoards();
const allPictureTags = useAllPictureTags();
const allImageTags = useAllImageTags();
// Provide data to child components via Svelte context
setContext('allImages', allImages);
setContext('archivedImages', archivedImages);
setContext('allBoards', allBoards);
setContext('pictureTags', allPictureTags);
setContext('allImageTags', allImageTags);
// Initialize view preferences
pictureViewStore.initialize();
</script>
{@render children()}

View file

@ -0,0 +1,300 @@
<script lang="ts">
import { getContext } from 'svelte';
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
import { pictureViewStore } from '$lib/modules/picture/stores/view.svelte';
import { getFavoriteImages, getImagesByTags } from '$lib/modules/picture/queries';
import type { Image, LocalPictureTag, LocalImageTag } from '$lib/modules/picture/types';
import {
Heart,
SquaresFour,
Rows,
GridFour,
Plus,
MagnifyingGlass,
Star,
Archive,
} from '@manacore/shared-icons';
const allImages: { value: Image[] } = getContext('allImages');
const allPictureTags: { value: LocalPictureTag[] } = getContext('pictureTags');
const allImageTags: { value: LocalImageTag[] } = getContext('allImageTags');
let searchQuery = $state('');
let selectedTagIds = $state<string[]>([]);
// Derive filtered images reactively
let filteredImages = $derived.by(() => {
let result = allImages.value;
if (imagesStore.showFavoritesOnly) {
result = getFavoriteImages(result);
}
if (selectedTagIds.length > 0) {
result = getImagesByTags(result, allImageTags.value, selectedTagIds);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter((img) => img.prompt.toLowerCase().includes(q));
}
return result;
});
function toggleTag(tagId: string) {
if (selectedTagIds.includes(tagId)) {
selectedTagIds = selectedTagIds.filter((id) => id !== tagId);
} else {
selectedTagIds = [...selectedTagIds, tagId];
}
}
// Grid columns based on view mode
let gridClass = $derived(
pictureViewStore.viewMode === 'single'
? 'grid-cols-1 max-w-2xl mx-auto'
: pictureViewStore.viewMode === 'grid3'
? 'grid-cols-2 sm:grid-cols-3'
: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5'
);
let selectedImage = $state<Image | null>(null);
async function handleToggleFavorite(img: Image) {
await imagesStore.toggleFavorite(img.id, img.isFavorite);
}
async function handleArchive(img: Image) {
await imagesStore.archiveImage(img.id);
selectedImage = null;
}
</script>
<svelte:head>
<title>Galerie - Picture - ManaCore</title>
</svelte:head>
<div class="flex h-full flex-col">
<!-- Header -->
<header class="border-b border-border px-4 py-3">
<div class="flex items-center justify-between mb-3">
<h1 class="text-lg font-semibold text-foreground">Galerie</h1>
<div class="flex items-center gap-2">
<!-- View Mode -->
<div class="flex rounded-lg border border-border bg-card">
<button
onclick={() => pictureViewStore.setViewMode('single')}
class="p-1.5 {pictureViewStore.viewMode === 'single'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'} rounded-l-lg transition-colors"
title="Liste"
>
<Rows size={16} />
</button>
<button
onclick={() => pictureViewStore.setViewMode('grid3')}
class="p-1.5 {pictureViewStore.viewMode === 'grid3'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'} transition-colors"
title="Mittel"
>
<GridFour size={16} />
</button>
<button
onclick={() => pictureViewStore.setViewMode('grid5')}
class="p-1.5 {pictureViewStore.viewMode === 'grid5'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'} rounded-r-lg transition-colors"
title="Klein"
>
<SquaresFour size={16} />
</button>
</div>
<a
href="/picture/generate"
class="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Generieren
</a>
</div>
</div>
<!-- Search & Filters -->
<div class="flex flex-wrap items-center gap-2">
<div class="relative flex-1 min-w-[200px]">
<MagnifyingGlass
size={16}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
bind:value={searchQuery}
placeholder="Prompts durchsuchen..."
class="w-full rounded-lg border border-border bg-background py-1.5 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<button
onclick={() => imagesStore.toggleFavoritesFilter()}
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors {imagesStore.showFavoritesOnly
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'}"
>
<Heart size={14} weight={imagesStore.showFavoritesOnly ? 'fill' : 'regular'} />
Favoriten
</button>
{#each allPictureTags.value as tag (tag.id)}
<button
onclick={() => toggleTag(tag.id)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {selectedTagIds.includes(
tag.id
)
? 'text-white'
: 'bg-muted text-muted-foreground hover:text-foreground'}"
style={selectedTagIds.includes(tag.id)
? `background-color: ${tag.color || '#6b7280'}`
: ''}
>
{tag.name}
</button>
{/each}
</div>
</header>
<!-- Gallery Grid -->
<div class="flex-1 overflow-auto p-4">
{#if filteredImages.length === 0}
<div class="flex flex-col items-center justify-center py-20">
<SquaresFour size={64} weight="thin" class="text-muted-foreground/30" />
<h3 class="mt-4 text-lg font-semibold text-foreground">
{allImages.value.length === 0 ? 'Noch keine Bilder' : 'Keine Ergebnisse'}
</h3>
<p class="mt-1 text-sm text-muted-foreground">
{allImages.value.length === 0
? 'Generiere dein erstes Bild mit KI'
: 'Passe deine Filter an'}
</p>
{#if allImages.value.length === 0}
<a
href="/picture/generate"
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Erstes Bild generieren
</a>
{/if}
</div>
{:else}
<div class="grid gap-3 {gridClass}">
{#each filteredImages as img (img.id)}
<button
onclick={() => (selectedImage = img)}
class="group relative overflow-hidden rounded-lg border border-border bg-card transition-all hover:shadow-lg hover:border-primary/50"
>
{#if img.publicUrl}
<img
src={img.publicUrl}
alt={img.prompt}
class="aspect-square w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
{:else}
<div class="flex aspect-square items-center justify-center bg-muted">
<SquaresFour size={32} class="text-muted-foreground/30" />
</div>
{/if}
<!-- Overlay on hover -->
<div
class="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity p-2"
>
<p class="text-xs text-white line-clamp-2">{img.prompt}</p>
</div>
<!-- Favorite indicator -->
{#if img.isFavorite}
<div class="absolute top-1.5 right-1.5">
<Heart size={16} weight="fill" class="text-red-500 drop-shadow" />
</div>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Image Detail Modal -->
{#if selectedImage}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div
class="relative max-h-[90vh] max-w-4xl w-full overflow-auto rounded-xl border border-border bg-card"
>
<!-- Image -->
<div class="relative bg-black flex items-center justify-center">
{#if selectedImage.publicUrl}
<img
src={selectedImage.publicUrl}
alt={selectedImage.prompt}
class="max-h-[60vh] w-full object-contain"
/>
{:else}
<div class="flex h-64 items-center justify-center">
<SquaresFour size={64} class="text-muted-foreground/30" />
</div>
{/if}
</div>
<!-- Info -->
<div class="p-4">
<p class="text-sm text-foreground">{selectedImage.prompt}</p>
{#if selectedImage.model}
<p class="mt-1 text-xs text-muted-foreground">Modell: {selectedImage.model}</p>
{/if}
{#if selectedImage.width && selectedImage.height}
<p class="text-xs text-muted-foreground">
{selectedImage.width} x {selectedImage.height}
</p>
{/if}
<p class="text-xs text-muted-foreground">
{new Date(selectedImage.createdAt).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
<div class="mt-3 flex gap-2">
<button
onclick={() => selectedImage && handleToggleFavorite(selectedImage)}
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium border border-border hover:bg-muted transition-colors"
>
<Heart
size={16}
weight={selectedImage.isFavorite ? 'fill' : 'regular'}
class={selectedImage.isFavorite ? 'text-red-500' : 'text-muted-foreground'}
/>
{selectedImage.isFavorite ? 'Entfernen' : 'Favorit'}
</button>
<button
onclick={() => selectedImage && handleArchive(selectedImage)}
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium border border-border hover:bg-muted transition-colors"
>
<Archive size={16} class="text-muted-foreground" />
Archivieren
</button>
<div class="flex-1"></div>
<button
onclick={() => (selectedImage = null)}
class="rounded-lg border border-border px-4 py-1.5 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { getContext } from 'svelte';
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
import { pictureViewStore } from '$lib/modules/picture/stores/view.svelte';
import type { Image } from '$lib/modules/picture/types';
import { SquaresFour, ArrowCounterClockwise, Trash, Archive } from '@manacore/shared-icons';
const archivedImages: { value: Image[] } = getContext('archivedImages');
let gridClass = $derived(
pictureViewStore.viewMode === 'single'
? 'grid-cols-1 max-w-2xl mx-auto'
: pictureViewStore.viewMode === 'grid3'
? 'grid-cols-2 sm:grid-cols-3'
: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5'
);
async function handleRestore(img: Image) {
await imagesStore.restoreImage(img.id);
}
async function handleDelete(img: Image) {
if (!confirm('Bild endgültig löschen?')) return;
await imagesStore.deleteImage(img.id);
}
</script>
<svelte:head>
<title>Archiv - Picture - ManaCore</title>
</svelte:head>
<div class="p-4">
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Archiv</h1>
<p class="mt-1 text-sm text-muted-foreground">
Archivierte Bilder werden nicht in der Galerie angezeigt
</p>
</header>
{#if archivedImages.value.length === 0}
<div class="flex flex-col items-center justify-center py-20">
<Archive size={64} weight="thin" class="text-muted-foreground/30" />
<h3 class="mt-4 text-lg font-semibold text-foreground">Archiv ist leer</h3>
<p class="mt-1 text-sm text-muted-foreground">Archivierte Bilder erscheinen hier</p>
</div>
{:else}
<div class="grid gap-3 {gridClass}">
{#each archivedImages.value as img (img.id)}
<div class="group relative overflow-hidden rounded-lg border border-border bg-card">
{#if img.publicUrl}
<img
src={img.publicUrl}
alt={img.prompt}
class="aspect-square w-full object-cover opacity-70"
loading="lazy"
/>
{:else}
<div class="flex aspect-square items-center justify-center bg-muted">
<SquaresFour size={32} class="text-muted-foreground/30" />
</div>
{/if}
<!-- Actions Overlay -->
<div
class="absolute inset-0 flex items-center justify-center gap-2 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={() => handleRestore(img)}
class="rounded-lg bg-white/90 p-2 text-foreground hover:bg-white transition-colors"
title="Wiederherstellen"
>
<ArrowCounterClockwise size={18} />
</button>
<button
onclick={() => handleDelete(img)}
class="rounded-lg bg-red-500/90 p-2 text-white hover:bg-red-600 transition-colors"
title="Endgültig löschen"
>
<Trash size={18} />
</button>
</div>
<div class="p-2">
<p class="text-xs text-muted-foreground line-clamp-1">{img.prompt}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,240 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { boardsStore } from '$lib/modules/picture/stores/boards.svelte';
import type { BoardWithCount } from '$lib/modules/picture/types';
import { Plus, SquaresFour, Image, Trash, Copy } from '@manacore/shared-icons';
const allBoards: { value: BoardWithCount[] } = getContext('allBoards');
// Create board form
let showCreateForm = $state(false);
let boardName = $state('');
let boardDescription = $state('');
let isCreating = $state(false);
// Delete confirmation
let showDeleteConfirm = $state(false);
let deletingBoardId = $state<string | null>(null);
async function handleCreateBoard() {
if (!boardName.trim()) return;
isCreating = true;
const result = await boardsStore.createBoard({
name: boardName,
description: boardDescription || undefined,
});
if (result.success) {
boardName = '';
boardDescription = '';
showCreateForm = false;
}
isCreating = false;
}
async function handleDeleteBoard() {
if (!deletingBoardId) return;
await boardsStore.deleteBoard(deletingBoardId);
showDeleteConfirm = false;
deletingBoardId = null;
}
async function handleDuplicate(boardId: string) {
await boardsStore.duplicateBoard(boardId);
}
function confirmDelete(boardId: string) {
deletingBoardId = boardId;
showDeleteConfirm = true;
}
</script>
<svelte:head>
<title>Moodboards - Picture - ManaCore</title>
</svelte:head>
<div class="p-4">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Moodboards</h1>
<p class="mt-1 text-sm text-muted-foreground">
Erstelle und organisiere deine Bilder auf einem Canvas
</p>
</div>
<button
onclick={() => (showCreateForm = true)}
class="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Neues Board
</button>
</div>
{#if allBoards.value.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-20">
<SquaresFour size={96} weight="thin" class="text-muted-foreground/30" />
<h3 class="mt-4 text-xl font-semibold text-foreground">Keine Boards vorhanden</h3>
<p class="mt-2 text-muted-foreground">
Erstelle dein erstes Moodboard und organisiere deine Bilder
</p>
<button
onclick={() => (showCreateForm = true)}
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Erstes Board erstellen
</button>
</div>
{:else}
<!-- Boards Grid -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each allBoards.value as board (board.id)}
<div
class="group relative overflow-hidden rounded-lg border border-border bg-card transition-all hover:shadow-lg hover:border-primary/50"
>
<!-- Thumbnail -->
<button
onclick={() => goto(`/picture/board/${board.id}`)}
class="block w-full overflow-hidden"
style="aspect-ratio: 4/3; background-color: {board.backgroundColor || '#ffffff'}"
>
{#if board.thumbnailUrl}
<img
src={board.thumbnailUrl}
alt={board.name}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full items-center justify-center">
<Image size={48} weight="thin" class="text-muted-foreground/30" />
</div>
{/if}
</button>
<!-- Info -->
<div class="p-3">
<button onclick={() => goto(`/picture/board/${board.id}`)} class="w-full text-left">
<h3 class="font-semibold text-foreground">{board.name}</h3>
{#if board.description}
<p class="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
{board.description}
</p>
{/if}
</button>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{board.itemCount} {board.itemCount === 1 ? 'Element' : 'Elemente'}</span>
<span>{new Date(board.updatedAt).toLocaleDateString('de-DE')}</span>
</div>
<!-- Actions -->
<div class="mt-2 flex gap-2">
<button
onclick={() => handleDuplicate(board.id)}
class="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors flex-1"
>
<Copy size={12} />
Duplizieren
</button>
<button
onclick={() => confirmDelete(board.id)}
class="rounded-lg border border-border px-2 py-1 text-muted-foreground hover:text-red-600 hover:border-red-200 transition-colors"
>
<Trash size={14} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Board Modal -->
{#if showCreateForm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl">
<h2 class="mb-4 text-xl font-semibold text-foreground">Neues Board erstellen</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreateBoard();
}}
class="space-y-4"
>
<div>
<label for="board-name" class="mb-1 block text-sm font-medium text-foreground">Name</label
>
<input
id="board-name"
type="text"
bind:value={boardName}
placeholder="Mein Moodboard"
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="board-desc" class="mb-1 block text-sm font-medium text-foreground">
Beschreibung (optional)
</label>
<textarea
id="board-desc"
bind:value={boardDescription}
placeholder="Beschreibe dein Board..."
rows="3"
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
></textarea>
</div>
<div class="flex gap-3 pt-2">
<button
type="button"
onclick={() => (showCreateForm = false)}
class="flex-1 rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!boardName.trim() || isCreating}
class="flex-1 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{isCreating ? 'Erstelle...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-xl border border-border bg-card p-6 shadow-xl">
<h2 class="text-lg font-semibold text-foreground">Board löschen?</h2>
<p class="mt-2 text-sm text-muted-foreground">
Möchtest du dieses Board wirklich löschen? Alle Bilder auf dem Board bleiben in deiner
Galerie erhalten.
</p>
<div class="mt-4 flex gap-3">
<button
onclick={() => (showDeleteConfirm = false)}
class="flex-1 rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Abbrechen
</button>
<button
onclick={handleDeleteBoard}
class="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition-colors"
>
Löschen
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { boardsStore } from '$lib/modules/picture/stores/boards.svelte';
import { findBoardById } from '$lib/modules/picture/queries';
import type { BoardWithCount } from '$lib/modules/picture/types';
import { CaretLeft, Trash, PencilSimple, Image } from '@manacore/shared-icons';
const allBoards: { value: BoardWithCount[] } = getContext('allBoards');
let boardId = $derived($page.params.id);
let board = $derived(findBoardById(allBoards.value, boardId));
// Edit state
let isEditing = $state(false);
let editName = $state('');
let editDescription = $state('');
function startEditing() {
if (!board) return;
editName = board.name;
editDescription = board.description ?? '';
isEditing = true;
}
async function handleSave() {
if (!board) return;
await boardsStore.updateBoard(board.id, {
name: editName,
description: editDescription || null,
});
isEditing = false;
}
async function handleDelete() {
if (!board) return;
await boardsStore.deleteBoard(board.id);
goto('/picture/board');
}
</script>
<svelte:head>
<title>{board?.name ?? 'Board'} - Picture - ManaCore</title>
</svelte:head>
<div class="flex h-full flex-col">
{#if !board}
<div class="flex flex-col items-center justify-center py-20">
<p class="text-lg text-muted-foreground">Board nicht gefunden</p>
<a href="/picture/board" class="mt-4 text-sm text-primary hover:underline">
Zurück zu den Boards
</a>
</div>
{:else}
<!-- Toolbar -->
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex items-center gap-3">
<a
href="/picture/board"
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<CaretLeft size={18} />
</a>
<div>
<h1 class="font-semibold text-foreground">{board.name}</h1>
{#if board.description}
<p class="text-xs text-muted-foreground">{board.description}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<button
onclick={startEditing}
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="Bearbeiten"
>
<PencilSimple size={18} />
</button>
<button
onclick={handleDelete}
class="rounded-lg p-1.5 text-muted-foreground hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/20 transition-colors"
title="Löschen"
>
<Trash size={18} />
</button>
</div>
</header>
<!-- Canvas Area (placeholder) -->
<div
class="flex-1 flex items-center justify-center"
style="background-color: {board.backgroundColor}"
>
<div class="text-center">
<Image size={64} weight="thin" class="mx-auto text-muted-foreground/30" />
<p class="mt-4 text-lg font-medium text-muted-foreground">Canvas-Editor</p>
<p class="mt-1 text-sm text-muted-foreground">
Der vollständige Canvas-Editor mit Drag-and-Drop wird in einem zukünftigen Update
hinzugefügt.
</p>
<p class="mt-2 text-xs text-muted-foreground">
{board.canvasWidth} x {board.canvasHeight} px &middot; {board.itemCount}
{board.itemCount === 1 ? 'Element' : 'Elemente'}
</p>
</div>
</div>
{/if}
</div>
<!-- Edit Modal -->
{#if isEditing && board}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl">
<h2 class="mb-4 text-xl font-semibold text-foreground">Board bearbeiten</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
class="space-y-4"
>
<div>
<label for="edit-name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
<input
id="edit-name"
type="text"
bind:value={editName}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="edit-desc" class="mb-1 block text-sm font-medium text-foreground"
>Beschreibung</label
>
<textarea
id="edit-desc"
bind:value={editDescription}
rows="3"
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
></textarea>
</div>
<div class="flex gap-3 pt-2">
<button
type="button"
onclick={() => (isEditing = false)}
class="flex-1 rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Abbrechen
</button>
<button
type="submit"
class="flex-1 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Speichern
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { CheckCircle, Sparkle, Lightning } from '@manacore/shared-icons';
let prompt = $state('');
let negativePrompt = $state('');
let isGenerating = $state(false);
let generationError = $state('');
async function handleGenerate() {
if (!prompt.trim()) return;
isGenerating = true;
generationError = '';
try {
// TODO: Connect to Picture backend API for image generation
// For now, show a placeholder message
await new Promise((resolve) => setTimeout(resolve, 2000));
generationError = 'Bildgenerierung erfordert eine Verbindung zum Picture-Server (Port 3006).';
} catch (e) {
generationError = e instanceof Error ? e.message : 'Generierung fehlgeschlagen';
} finally {
isGenerating = false;
}
}
const PROMPT_SUGGESTIONS = [
'Ein traumhafter Sonnenuntergang über dem Bodensee',
'Futuristisches Stadtbild in Neonfarben, cyberpunk',
'Aquarell eines ruhigen japanischen Gartens',
'Abstrakte Kunst in lebhaften Blau- und Goldtönen',
'Photorealistisches Portrait einer Katze als Ritter',
];
</script>
<svelte:head>
<title>Generieren - Picture - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl p-4">
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Bild generieren</h1>
<p class="mt-1 text-sm text-muted-foreground">
Erstelle beeindruckende KI-Bilder aus deinen Textbeschreibungen
</p>
</header>
<!-- Generate Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleGenerate();
}}
class="space-y-4"
>
<!-- Prompt -->
<div>
<label for="prompt" class="mb-1 block text-sm font-medium text-foreground"> Prompt </label>
<textarea
id="prompt"
bind:value={prompt}
placeholder="Beschreibe das Bild, das du erstellen möchtest..."
rows="4"
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary resize-none"
></textarea>
</div>
<!-- Negative Prompt -->
<div>
<label for="negative-prompt" class="mb-1 block text-sm font-medium text-foreground">
Negativ-Prompt (optional)
</label>
<input
id="negative-prompt"
type="text"
bind:value={negativePrompt}
placeholder="Was soll nicht im Bild sein... (z.B. unscharf, verzerrt)"
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<!-- Generate Button -->
<button
type="submit"
disabled={!prompt.trim() || isGenerating}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{#if isGenerating}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></div>
Generiere...
{:else}
<Sparkle size={18} />
Bild generieren
{/if}
</button>
{#if generationError}
<div
class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/20 dark:text-amber-200"
>
{generationError}
</div>
{/if}
</form>
<!-- Prompt Suggestions -->
<div class="mt-8">
<h2 class="mb-3 text-sm font-medium text-muted-foreground uppercase tracking-wide">
Prompt-Vorschläge
</h2>
<div class="flex flex-wrap gap-2">
{#each PROMPT_SUGGESTIONS as suggestion}
<button
onclick={() => (prompt = suggestion)}
class="rounded-full border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/50 hover:text-foreground transition-colors"
>
{suggestion}
</button>
{/each}
</div>
</div>
<!-- Tips -->
<div class="mt-8 rounded-lg border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-foreground">Tipps für bessere Ergebnisse</h3>
<ul class="space-y-2 text-sm text-muted-foreground">
<li class="flex items-start gap-2">
<CheckCircle size={16} class="mt-0.5 flex-shrink-0 text-primary" />
<span
><strong class="text-foreground">Sei spezifisch:</strong> Beschreibe Stil, Stimmung, Farben
und Komposition</span
>
</li>
<li class="flex items-start gap-2">
<CheckCircle size={16} class="mt-0.5 flex-shrink-0 text-primary" />
<span
><strong class="text-foreground">Beschreibende Wörter:</strong> "Lebhafter Sonnenuntergang
über Bergen" ist besser als "Sonnenuntergang"</span
>
</li>
<li class="flex items-start gap-2">
<CheckCircle size={16} class="mt-0.5 flex-shrink-0 text-primary" />
<span
><strong class="text-foreground">Negativ-Prompts:</strong> Schließe unerwünschte Elemente aus
(z.B. "unscharf, verzerrt, niedrige Qualität")</span
>
</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import {
useAllTasks,
useAllLabels,
useAllBoardViews,
useAllProjects,
} from '$lib/modules/todo/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allTasks = useAllTasks();
const allLabels = useAllLabels();
const boardViews = useAllBoardViews();
const allProjects = useAllProjects();
// Provide data to child components via Svelte context
setContext('tasks', allTasks);
setContext('labels', allLabels);
setContext('boardViews', boardViews);
setContext('projects', allProjects);
</script>
{@render children()}

View file

@ -0,0 +1,471 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import {
type Task,
type LocalLabel,
type LocalBoardView,
type LocalTodoProject,
type TaskPriority,
tasksStore,
viewStore,
filterIncomplete,
filterCompleted,
filterOverdue,
filterToday,
filterUpcoming,
filterByProject,
searchTasks,
sortTasks,
getPriorityLabel,
getPriorityColor,
getTaskStats,
} from '$lib/modules/todo';
import {
Plus,
Check,
Circle,
MagnifyingGlass,
Tray,
CalendarBlank,
CalendarCheck,
Flag,
FunnelSimple,
CaretRight,
Folder,
CheckCircle,
} from '@manacore/shared-icons';
// Get data from layout context
const allTasks$: Observable<Task[]> = getContext('tasks');
const allLabels$: Observable<LocalLabel[]> = getContext('labels');
const allProjects$: Observable<LocalTodoProject[]> = getContext('projects');
let allTasks = $state<Task[]>([]);
let allLabels = $state<LocalLabel[]>([]);
let allProjects = $state<LocalTodoProject[]>([]);
$effect(() => {
const sub = allTasks$.subscribe((t) => {
allTasks = t;
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = allLabels$.subscribe((l) => {
allLabels = l;
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = allProjects$.subscribe((p) => {
allProjects = p;
});
return () => sub.unsubscribe();
});
// Task stats
let stats = $derived(getTaskStats(allTasks));
// Filtered tasks based on current view
let displayTasks = $derived.by(() => {
let tasks = allTasks;
switch (viewStore.currentView) {
case 'today':
tasks = [...filterOverdue(allTasks), ...filterToday(allTasks)];
break;
case 'upcoming':
tasks = filterUpcoming(allTasks);
break;
case 'completed':
tasks = filterCompleted(allTasks);
break;
case 'search':
tasks = searchTasks(allTasks, viewStore.searchQuery);
break;
default:
tasks = filterIncomplete(allTasks);
}
return sortTasks(tasks, viewStore.sortBy, viewStore.sortOrder);
});
// Quick add task
let newTaskTitle = $state('');
let isAdding = $state(false);
async function handleQuickAdd() {
if (!newTaskTitle.trim()) return;
await tasksStore.createTask({ title: newTaskTitle.trim() });
newTaskTitle = '';
isAdding = false;
}
async function handleToggleComplete(e: MouseEvent, task: Task) {
e.stopPropagation();
e.preventDefault();
await tasksStore.toggleComplete(task.id);
}
async function handleDeleteTask(e: MouseEvent, task: Task) {
e.stopPropagation();
e.preventDefault();
await tasksStore.deleteTask(task.id);
}
// View navigation items
const views = [
{ id: 'inbox', label: 'Inbox', icon: Tray },
{ id: 'today', label: 'Heute', icon: CalendarBlank },
{ id: 'upcoming', label: 'Bald faellig', icon: CalendarCheck },
{ id: 'completed', label: 'Erledigt', icon: CheckCircle },
] as const;
let selectedTaskId = $state<string | null>(null);
let selectedTask = $derived(allTasks.find((t) => t.id === selectedTaskId));
function formatDueDate(date: string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrowStart = new Date(todayStart);
tomorrowStart.setDate(tomorrowStart.getDate() + 1);
if (d < todayStart) return 'Ueberfaellig';
if (d >= todayStart && d < tomorrowStart) return 'Heute';
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
function getDueDateColor(date: string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (d < todayStart) return 'text-red-500';
return 'text-muted-foreground';
}
</script>
<svelte:head>
<title>Todo - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<!-- Header with Stats -->
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Todo</h1>
<div class="mt-2 flex gap-4 text-sm text-muted-foreground">
<span>{stats.total} Aufgaben</span>
<span>{stats.completed} erledigt</span>
{#if stats.overdue > 0}
<span class="text-red-500">{stats.overdue} ueberfaellig</span>
{/if}
{#if stats.today > 0}
<span class="text-amber-500">{stats.today} heute</span>
{/if}
</div>
</header>
<!-- View Tabs -->
<div class="mb-4 flex gap-1 rounded-lg border border-border bg-card p-1">
{#each views as view}
<button
onclick={() => {
switch (view.id) {
case 'inbox':
viewStore.setInbox();
break;
case 'today':
viewStore.setToday();
break;
case 'upcoming':
viewStore.setUpcoming();
break;
case 'completed':
viewStore.setCompleted();
break;
}
}}
class="flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors
{viewStore.currentView === view.id
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<view.icon size={16} />
<span class="hidden sm:inline">{view.label}</span>
</button>
{/each}
</div>
<!-- Search (for search view) -->
{#if viewStore.currentView === 'search'}
<div class="relative mb-4">
<MagnifyingGlass
size={18}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
placeholder="Aufgaben suchen..."
value={viewStore.searchQuery}
oninput={(e) => 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"
/>
</div>
{/if}
<!-- Quick Add -->
{#if isAdding}
<div class="mb-4 rounded-lg border border-primary bg-card p-3">
<form
onsubmit={(e) => {
e.preventDefault();
handleQuickAdd();
}}
class="flex gap-2"
>
<input
type="text"
placeholder="Was moechtest du erledigen?"
bind:value={newTaskTitle}
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autofocus
/>
<button
type="submit"
disabled={!newTaskTitle.trim()}
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
>
Hinzufuegen
</button>
<button
type="button"
onclick={() => {
isAdding = false;
newTaskTitle = '';
}}
class="text-xs text-muted-foreground hover:text-foreground"
>
Abbrechen
</button>
</form>
</div>
{:else}
<button
onclick={() => (isAdding = true)}
class="mb-4 flex w-full items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<Plus size={16} />
Neue Aufgabe
</button>
{/if}
<!-- Task List -->
{#if displayTasks.length === 0}
<div class="flex flex-col items-center py-12 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Tray size={32} class="text-muted-foreground" />
</div>
<h2 class="mb-1 text-lg font-semibold text-foreground">
{#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}
</h2>
<p class="text-sm text-muted-foreground">
{#if viewStore.currentView === 'inbox'}
Erstelle deine erste Aufgabe mit dem + Button oben.
{/if}
</p>
</div>
{:else}
<div class="space-y-1">
{#each displayTasks as task (task.id)}
<div
class="group flex items-start gap-3 rounded-lg border border-transparent px-3 py-2.5 transition-colors hover:border-border hover:bg-card"
role="button"
tabindex="0"
onclick={() => (selectedTaskId = selectedTaskId === task.id ? null : task.id)}
>
<!-- Completion Toggle -->
<button
onclick={(e) => handleToggleComplete(e, task)}
class="mt-0.5 flex-shrink-0 transition-colors
{task.isCompleted ? 'text-green-500' : `text-muted-foreground hover:text-primary`}"
title={task.isCompleted ? 'Als offen markieren' : 'Als erledigt markieren'}
>
{#if task.isCompleted}
<Check size={20} weight="bold" />
{:else}
<Circle size={20} />
{/if}
</button>
<!-- Task Content -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="text-sm {task.isCompleted
? 'line-through text-muted-foreground'
: 'text-foreground'}"
>
{task.title}
</span>
</div>
<!-- Metadata Row -->
<div class="mt-0.5 flex items-center gap-3 text-xs">
{#if task.dueDate}
<span class={getDueDateColor(task.dueDate)}>
{formatDueDate(task.dueDate)}
</span>
{/if}
{#if task.priority !== 'medium'}
<span style="color: {getPriorityColor(task.priority)}">
{getPriorityLabel(task.priority)}
</span>
{/if}
{#if task.subtasks?.length}
<span class="text-muted-foreground">
{task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks.length} Teilaufgaben
</span>
{/if}
</div>
<!-- Expanded Detail -->
{#if selectedTaskId === task.id}
<div class="mt-3 space-y-2 border-t border-border pt-3">
{#if task.description}
<p class="text-sm text-muted-foreground">{task.description}</p>
{/if}
{#if task.subtasks?.length}
<div class="space-y-1">
{#each task.subtasks as subtask (subtask.id)}
<div class="flex items-center gap-2 text-sm">
{#if subtask.isCompleted}
<Check size={14} class="text-green-500" />
{:else}
<Circle size={14} class="text-muted-foreground" />
{/if}
<span
class={subtask.isCompleted
? 'line-through text-muted-foreground'
: 'text-foreground'}
>
{subtask.title}
</span>
</div>
{/each}
</div>
{/if}
<div class="flex gap-2 pt-1">
<select
value={task.priority}
onchange={(e) =>
tasksStore.updateTask(task.id, {
priority: e.currentTarget.value as TaskPriority,
})}
class="rounded-md border border-border bg-background px-2 py-1 text-xs focus:border-primary focus:outline-none"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="urgent">Dringend</option>
</select>
<input
type="date"
value={task.dueDate ? task.dueDate.split('T')[0] : ''}
onchange={(e) =>
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"
/>
<button
onclick={(e) => handleDeleteTask(e, task)}
class="ml-auto rounded-md px-2 py-1 text-xs text-red-500 transition-colors hover:bg-red-500/10"
>
Loeschen
</button>
</div>
</div>
{/if}
</div>
<!-- Priority indicator -->
{#if task.priority === 'urgent' || task.priority === 'high'}
<div
class="mt-1 h-2 w-2 flex-shrink-0 rounded-full"
style="background-color: {getPriorityColor(task.priority)}"
title={getPriorityLabel(task.priority)}
></div>
{/if}
</div>
{/each}
</div>
<p class="mt-4 text-center text-xs text-muted-foreground">
{displayTasks.length} Aufgabe{displayTasks.length !== 1 ? 'n' : ''}
</p>
{/if}
<!-- Projects Section -->
{#if allProjects.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Projekte
</h2>
<div class="space-y-1">
{#each allProjects as project (project.id)}
<button
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors hover:bg-card"
>
<div
class="h-3 w-3 rounded-sm"
style="background-color: {project.color ?? '#6b7280'}"
></div>
<span class="flex-1 text-left text-foreground">{project.name}</span>
<CaretRight size={14} class="text-muted-foreground" />
</button>
{/each}
</div>
</div>
{/if}
<!-- Labels Section -->
{#if allLabels.length > 0}
<div class="mt-6">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Labels
</h2>
<div class="flex flex-wrap gap-2">
{#each allLabels as label (label.id)}
<button
onclick={() => viewStore.setLabel(label.id)}
class="rounded-full border px-3 py-1 text-xs font-medium transition-colors
{viewStore.currentView === 'label' && viewStore.currentLabelId === label.id
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-primary/50'}"
>
<span
class="mr-1 inline-block h-2 w-2 rounded-full"
style="background-color: {label.color}"
></span>
{label.name}
</button>
{/each}
</div>
</div>
{/if}
</div>