mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
933715c7d9
commit
9b614cdfbc
82 changed files with 10802 additions and 0 deletions
|
|
@ -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[];
|
||||
})(),
|
||||
};
|
||||
26
apps/manacore/apps/web/src/lib/modules/calendar/index.ts
Normal file
26
apps/manacore/apps/web/src/lib/modules/calendar/index.ts
Normal 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';
|
||||
165
apps/manacore/apps/web/src/lib/modules/calendar/queries.ts
Normal file
165
apps/manacore/apps/web/src/lib/modules/calendar/queries.ts
Normal 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()
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
56
apps/manacore/apps/web/src/lib/modules/calendar/types.ts
Normal file
56
apps/manacore/apps/web/src/lib/modules/calendar/types.ts
Normal 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;
|
||||
}
|
||||
41
apps/manacore/apps/web/src/lib/modules/chat/collections.ts
Normal file
41
apps/manacore/apps/web/src/lib/modules/chat/collections.ts
Normal 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>[],
|
||||
};
|
||||
31
apps/manacore/apps/web/src/lib/modules/chat/index.ts
Normal file
31
apps/manacore/apps/web/src/lib/modules/chat/index.ts
Normal 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';
|
||||
141
apps/manacore/apps/web/src/lib/modules/chat/queries.ts
Normal file
141
apps/manacore/apps/web/src/lib/modules/chat/queries.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
88
apps/manacore/apps/web/src/lib/modules/chat/types.ts
Normal file
88
apps/manacore/apps/web/src/lib/modules/chat/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[],
|
||||
};
|
||||
22
apps/manacore/apps/web/src/lib/modules/contacts/index.ts
Normal file
22
apps/manacore/apps/web/src/lib/modules/contacts/index.ts
Normal 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';
|
||||
122
apps/manacore/apps/web/src/lib/modules/contacts/queries.ts
Normal file
122
apps/manacore/apps/web/src/lib/modules/contacts/queries.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
47
apps/manacore/apps/web/src/lib/modules/contacts/types.ts
Normal file
47
apps/manacore/apps/web/src/lib/modules/contacts/types.ts
Normal 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';
|
||||
84
apps/manacore/apps/web/src/lib/modules/memoro/collections.ts
Normal file
84
apps/manacore/apps/web/src/lib/modules/memoro/collections.ts
Normal 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>[],
|
||||
};
|
||||
49
apps/manacore/apps/web/src/lib/modules/memoro/index.ts
Normal file
49
apps/manacore/apps/web/src/lib/modules/memoro/index.ts
Normal 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';
|
||||
183
apps/manacore/apps/web/src/lib/modules/memoro/queries.ts
Normal file
183
apps/manacore/apps/web/src/lib/modules/memoro/queries.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
127
apps/manacore/apps/web/src/lib/modules/memoro/types.ts
Normal file
127
apps/manacore/apps/web/src/lib/modules/memoro/types.ts
Normal 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;
|
||||
}
|
||||
41
apps/manacore/apps/web/src/lib/modules/mukke/collections.ts
Normal file
41
apps/manacore/apps/web/src/lib/modules/mukke/collections.ts
Normal 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>[],
|
||||
};
|
||||
52
apps/manacore/apps/web/src/lib/modules/mukke/index.ts
Normal file
52
apps/manacore/apps/web/src/lib/modules/mukke/index.ts
Normal 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';
|
||||
247
apps/manacore/apps/web/src/lib/modules/mukke/queries.ts
Normal file
247
apps/manacore/apps/web/src/lib/modules/mukke/queries.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
122
apps/manacore/apps/web/src/lib/modules/mukke/types.ts
Normal file
122
apps/manacore/apps/web/src/lib/modules/mukke/types.ts
Normal 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';
|
||||
|
|
@ -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[],
|
||||
};
|
||||
41
apps/manacore/apps/web/src/lib/modules/picture/index.ts
Normal file
41
apps/manacore/apps/web/src/lib/modules/picture/index.ts
Normal 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';
|
||||
178
apps/manacore/apps/web/src/lib/modules/picture/queries.ts
Normal file
178
apps/manacore/apps/web/src/lib/modules/picture/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
110
apps/manacore/apps/web/src/lib/modules/picture/types.ts
Normal file
110
apps/manacore/apps/web/src/lib/modules/picture/types.ts
Normal 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;
|
||||
}
|
||||
149
apps/manacore/apps/web/src/lib/modules/todo/collections.ts
Normal file
149
apps/manacore/apps/web/src/lib/modules/todo/collections.ts
Normal 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[],
|
||||
};
|
||||
55
apps/manacore/apps/web/src/lib/modules/todo/index.ts
Normal file
55
apps/manacore/apps/web/src/lib/modules/todo/index.ts
Normal 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';
|
||||
207
apps/manacore/apps/web/src/lib/modules/todo/queries.ts
Normal file
207
apps/manacore/apps/web/src/lib/modules/todo/queries.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
128
apps/manacore/apps/web/src/lib/modules/todo/types.ts
Normal file
128
apps/manacore/apps/web/src/lib/modules/todo/types.ts
Normal 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';
|
||||
|
|
@ -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()}
|
||||
499
apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte
Normal file
499
apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
23
apps/manacore/apps/web/src/routes/(app)/chat/+layout.svelte
Normal file
23
apps/manacore/apps/web/src/routes/(app)/chat/+layout.svelte
Normal 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()}
|
||||
242
apps/manacore/apps/web/src/routes/(app)/chat/+page.svelte
Normal file
242
apps/manacore/apps/web/src/routes/(app)/chat/+page.svelte
Normal 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>
|
||||
216
apps/manacore/apps/web/src/routes/(app)/chat/[id]/+page.svelte
Normal file
216
apps/manacore/apps/web/src/routes/(app)/chat/[id]/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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()}
|
||||
344
apps/manacore/apps/web/src/routes/(app)/contacts/+page.svelte
Normal file
344
apps/manacore/apps/web/src/routes/(app)/contacts/+page.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()}
|
||||
262
apps/manacore/apps/web/src/routes/(app)/memoro/+page.svelte
Normal file
262
apps/manacore/apps/web/src/routes/(app)/memoro/+page.svelte
Normal 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>
|
||||
289
apps/manacore/apps/web/src/routes/(app)/memoro/[id]/+page.svelte
Normal file
289
apps/manacore/apps/web/src/routes/(app)/memoro/[id]/+page.svelte
Normal 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}
|
||||
· {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}
|
||||
|
|
@ -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>
|
||||
224
apps/manacore/apps/web/src/routes/(app)/memoro/tags/+page.svelte
Normal file
224
apps/manacore/apps/web/src/routes/(app)/memoro/tags/+page.svelte
Normal 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}
|
||||
26
apps/manacore/apps/web/src/routes/(app)/mukke/+layout.svelte
Normal file
26
apps/manacore/apps/web/src/routes/(app)/mukke/+layout.svelte
Normal 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()}
|
||||
125
apps/manacore/apps/web/src/routes/(app)/mukke/+page.svelte
Normal file
125
apps/manacore/apps/web/src/routes/(app)/mukke/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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()}
|
||||
300
apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte
Normal file
300
apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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 · {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}
|
||||
|
|
@ -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>
|
||||
26
apps/manacore/apps/web/src/routes/(app)/todo/+layout.svelte
Normal file
26
apps/manacore/apps/web/src/routes/(app)/todo/+layout.svelte
Normal 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()}
|
||||
471
apps/manacore/apps/web/src/routes/(app)/todo/+page.svelte
Normal file
471
apps/manacore/apps/web/src/routes/(app)/todo/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue