mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(apps): migrate Calendar, Clock, Contacts, ManaDeck to local-first
Roll out @manacore/local-store to 4 more apps: - Clock: alarms, timers, world clocks in IndexedDB with guest seed - Calendar: calendars, events in IndexedDB with sample events - Contacts: contacts in IndexedDB with 3 sample contacts - ManaDeck: decks, cards in IndexedDB with onboarding flashcards All apps: GuestWelcomeModal, login pill for guests, sync on auth. Dev scripts: added dev:sync, dev:todo:server, dev:todo:local, dev:todo:full updated. 6 of 8 web apps are now local-first (Todo, Zitare, Clock, Calendar, Contacts, ManaDeck). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
427195d6dc
commit
2c9a36828f
25 changed files with 1585 additions and 755 deletions
|
|
@ -55,6 +55,7 @@
|
|||
"@manacore/shared-help-types": "workspace:*",
|
||||
"@manacore/shared-help-ui": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
|
|
|
|||
74
apps/calendar/apps/web/src/lib/data/guest-seed.ts
Normal file
74
apps/calendar/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Guest seed data for the Calendar app.
|
||||
*
|
||||
* These records are loaded into IndexedDB when a new guest visits the app.
|
||||
* They provide a "Persoenlich" calendar with two sample events so the user
|
||||
* can immediately see how the app works.
|
||||
*/
|
||||
|
||||
import type { LocalCalendar, LocalEvent } from './local-store';
|
||||
|
||||
const PERSONAL_CALENDAR_ID = 'personal-calendar';
|
||||
|
||||
export const guestCalendars: LocalCalendar[] = [
|
||||
{
|
||||
id: PERSONAL_CALENDAR_ID,
|
||||
name: 'Persönlich',
|
||||
color: '#3B82F6',
|
||||
isDefault: true,
|
||||
isVisible: true,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
];
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
export const guestEvents: LocalEvent[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
59
apps/calendar/apps/web/src/lib/data/local-store.ts
Normal file
59
apps/calendar/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Calendar App — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* This is the single source of truth for all Calendar data.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import { guestCalendars, guestEvents } from './guest-seed';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const calendarStore = createLocalStore({
|
||||
appId: 'calendar',
|
||||
collections: [
|
||||
{
|
||||
name: 'calendars',
|
||||
indexes: ['isDefault', 'isVisible'],
|
||||
guestSeed: guestCalendars,
|
||||
},
|
||||
{
|
||||
name: 'events',
|
||||
indexes: ['calendarId', 'startDate', 'endDate', 'allDay', '[calendarId+startDate]'],
|
||||
guestSeed: guestEvents,
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const calendarCollection = calendarStore.collection<LocalCalendar>('calendars');
|
||||
export const eventCollection = calendarStore.collection<LocalEvent>('events');
|
||||
|
|
@ -1,28 +1,16 @@
|
|||
/**
|
||||
* Calendars Store - Manages user calendars using Svelte 5 runes
|
||||
* Supports both authenticated (cloud) and guest (session) modes
|
||||
* Calendars Store — Local-First with IndexedDB
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* Same public API as before so components don't break.
|
||||
*/
|
||||
|
||||
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/calendars';
|
||||
import { calendarCollection, type LocalCalendar } from '$lib/data/local-store';
|
||||
import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
|
||||
import { settingsStore } from './settings.svelte';
|
||||
import { authStore } from './auth.svelte';
|
||||
import { CalendarEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
// Guest calendar for unauthenticated users
|
||||
const GUEST_CALENDAR: Calendar = {
|
||||
id: 'session-calendar',
|
||||
userId: 'guest',
|
||||
name: 'Mein Kalender',
|
||||
color: '#3b82f6',
|
||||
isDefault: true,
|
||||
isVisible: true,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// State
|
||||
let calendars = $state<Calendar[]>([]);
|
||||
let loading = $state(false);
|
||||
|
|
@ -35,12 +23,27 @@ const birthdayCalendar: Calendar = {
|
|||
name: BIRTHDAY_CALENDAR.name,
|
||||
color: BIRTHDAY_CALENDAR.color,
|
||||
isDefault: false,
|
||||
isVisible: true, // Visibility controlled by settingsStore.showBirthdays
|
||||
isVisible: true,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/** Convert a LocalCalendar (IndexedDB) to the shared Calendar type. */
|
||||
function toCalendar(local: LocalCalendar): Calendar {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'guest',
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to safely get calendars array (Svelte 5 runes safety)
|
||||
function getCalendarsArray(): Calendar[] {
|
||||
const arr = calendars ?? [];
|
||||
|
|
@ -50,7 +53,6 @@ function getCalendarsArray(): Calendar[] {
|
|||
// Derived: all calendars including virtual birthday calendar
|
||||
const allCalendars = $derived.by(() => {
|
||||
const userCalendars = getCalendarsArray();
|
||||
// Add virtual birthday calendar if birthdays are enabled in settings
|
||||
if (settingsStore.showBirthdays) {
|
||||
return [...userCalendars, { ...birthdayCalendar, isVisible: true }];
|
||||
}
|
||||
|
|
@ -91,75 +93,86 @@ export const calendarsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch all calendars
|
||||
* In guest mode, returns a default local calendar
|
||||
* Load calendars from IndexedDB.
|
||||
*/
|
||||
async fetchCalendars() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Guest mode: return local calendar only
|
||||
if (!authStore.isAuthenticated) {
|
||||
calendars = [GUEST_CALENDAR];
|
||||
loading = false;
|
||||
return { data: { calendars: [GUEST_CALENDAR] }, error: null };
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
const result = await api.getCalendars();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
try {
|
||||
const localCalendars = await calendarCollection.getAll();
|
||||
calendars = localCalendars.map(toCalendar);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch calendars';
|
||||
console.error('Failed to fetch calendars:', e);
|
||||
calendars = [];
|
||||
} else {
|
||||
// API returns { calendars: [...] }
|
||||
const data = result.data as { calendars: Calendar[] } | null;
|
||||
calendars = data?.calendars || [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
return { data: { calendars }, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new calendar
|
||||
* Create a new calendar — writes to IndexedDB instantly.
|
||||
*/
|
||||
async createCalendar(data: CreateCalendarInput) {
|
||||
const result = await api.createCalendar(data);
|
||||
error = null;
|
||||
try {
|
||||
const newLocal: LocalCalendar = {
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name,
|
||||
color: data.color ?? '#3B82F6',
|
||||
isDefault: data.isDefault ?? false,
|
||||
isVisible: data.isVisible ?? true,
|
||||
timezone: data.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
|
||||
if (result.data) {
|
||||
calendars = [...calendars, result.data];
|
||||
const inserted = await calendarCollection.insert(newLocal);
|
||||
const newCalendar = toCalendar(inserted);
|
||||
calendars = [...calendars, newCalendar];
|
||||
CalendarEvents.calendarCreated();
|
||||
return { data: newCalendar, error: null };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to create calendar';
|
||||
error = msg;
|
||||
return { data: null, error: { message: msg } };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a calendar
|
||||
* Update a calendar — writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateCalendar(id: string, data: UpdateCalendarInput) {
|
||||
const result = await api.updateCalendar(id, data);
|
||||
|
||||
if (result.data) {
|
||||
calendars = getCalendarsArray().map((c) => (c.id === id ? result.data! : c));
|
||||
error = null;
|
||||
try {
|
||||
const updated = await calendarCollection.update(id, data as Partial<LocalCalendar>);
|
||||
if (updated) {
|
||||
const updatedCalendar = toCalendar(updated);
|
||||
calendars = getCalendarsArray().map((c) => (c.id === id ? updatedCalendar : c));
|
||||
return { data: updatedCalendar, error: null };
|
||||
}
|
||||
return { data: null, error: null };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to update calendar';
|
||||
error = msg;
|
||||
return { data: null, error: { message: msg } };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a calendar
|
||||
* Delete a calendar — removes from IndexedDB instantly.
|
||||
*/
|
||||
async deleteCalendar(id: string) {
|
||||
const result = await api.deleteCalendar(id);
|
||||
|
||||
if (!result.error) {
|
||||
error = null;
|
||||
try {
|
||||
await calendarCollection.delete(id);
|
||||
calendars = getCalendarsArray().filter((c) => c.id !== id);
|
||||
CalendarEvents.calendarDeleted();
|
||||
return { error: null };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to delete calendar';
|
||||
error = msg;
|
||||
return { error: { message: msg } };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -177,17 +190,30 @@ export const calendarsStore = {
|
|||
* Set a calendar as the default
|
||||
*/
|
||||
async setAsDefault(id: string) {
|
||||
const result = await api.updateCalendar(id, { isDefault: true });
|
||||
|
||||
if (result.data) {
|
||||
// Update local state: set this one as default, remove default from others
|
||||
calendars = getCalendarsArray().map((c) => ({
|
||||
...c,
|
||||
isDefault: c.id === id,
|
||||
}));
|
||||
error = null;
|
||||
try {
|
||||
// Remove default from all others first
|
||||
for (const cal of getCalendarsArray()) {
|
||||
if (cal.isDefault && cal.id !== id) {
|
||||
await calendarCollection.update(cal.id, { isDefault: false } as Partial<LocalCalendar>);
|
||||
}
|
||||
}
|
||||
// Set the new default
|
||||
const updated = await calendarCollection.update(id, {
|
||||
isDefault: true,
|
||||
} as Partial<LocalCalendar>);
|
||||
if (updated) {
|
||||
calendars = getCalendarsArray().map((c) => ({
|
||||
...c,
|
||||
isDefault: c.id === id,
|
||||
}));
|
||||
}
|
||||
return { data: updated ? toCalendar(updated) : null, error: null };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to set default';
|
||||
error = msg;
|
||||
return { data: null, error: { message: msg } };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -201,7 +227,6 @@ export const calendarsStore = {
|
|||
* Get calendar color by ID (with fallback)
|
||||
*/
|
||||
getColor(id: string) {
|
||||
// Handle virtual birthday calendar
|
||||
if (id === BIRTHDAY_CALENDAR.id) {
|
||||
return BIRTHDAY_CALENDAR.color;
|
||||
}
|
||||
|
|
@ -211,7 +236,6 @@ export const calendarsStore = {
|
|||
|
||||
/**
|
||||
* Toggle birthday calendar visibility
|
||||
* (This updates the settings store, not the calendar itself)
|
||||
*/
|
||||
toggleBirthdaysVisibility() {
|
||||
settingsStore.set('showBirthdays', !settingsStore.showBirthdays);
|
||||
|
|
@ -228,13 +252,19 @@ export const calendarsStore = {
|
|||
* Check if a calendar ID is the guest calendar
|
||||
*/
|
||||
isGuestCalendar(id: string) {
|
||||
return id === GUEST_CALENDAR.id;
|
||||
return id === 'personal-calendar';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the guest calendar ID
|
||||
*/
|
||||
get guestCalendarId() {
|
||||
return GUEST_CALENDAR.id;
|
||||
return 'personal-calendar';
|
||||
},
|
||||
|
||||
clear() {
|
||||
calendars = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
/**
|
||||
* Events Store - Manages calendar events using Svelte 5 runes
|
||||
* Events Store — Local-First with IndexedDB
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* Same public API as before so components don't break.
|
||||
*/
|
||||
|
||||
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
|
||||
import { parseRRule, generateOccurrences } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
import { eventCollection, type LocalEvent } from '$lib/data/local-store';
|
||||
import { format, isWithinInterval, isSameDay, differenceInMilliseconds } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { toastStore } from '@manacore/shared-ui';
|
||||
|
|
@ -21,6 +24,32 @@ let loadedRange = $state<{ start: Date; end: Date } | null>(null);
|
|||
// Draft event for quick create (temporary event shown in grid before saving)
|
||||
let draftEvent = $state<CalendarEvent | null>(null);
|
||||
|
||||
/** Convert a LocalEvent (IndexedDB) to the shared CalendarEvent type. */
|
||||
function toCalendarEvent(local: LocalEvent): CalendarEvent {
|
||||
return {
|
||||
id: local.id,
|
||||
calendarId: local.calendarId,
|
||||
userId: 'guest',
|
||||
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,
|
||||
recurrenceEndDate: null,
|
||||
recurrenceExceptions: null,
|
||||
parentEventId: null,
|
||||
color: local.color ?? null,
|
||||
status: 'confirmed',
|
||||
externalId: null,
|
||||
metadata: null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand recurring events into individual occurrences for the current view range.
|
||||
* Each occurrence gets a synthetic ID: `{parentId}__recurrence__{dateISO}`
|
||||
|
|
@ -84,38 +113,48 @@ export const eventsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch events for a date range
|
||||
* Fetch events for a date range — reads from IndexedDB.
|
||||
*/
|
||||
async fetchEvents(startDate: Date, endDate: Date, calendarIds?: string[]) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await api.getEvents({
|
||||
startDate: format(startDate, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
endDate: format(endDate, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
calendarIds,
|
||||
});
|
||||
try {
|
||||
const allEvents = await eventCollection.getAll();
|
||||
let mapped = allEvents.map(toCalendarEvent);
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
toastStore.error(get(_)('toast.eventLoadError') + ': ' + result.error.message);
|
||||
} else {
|
||||
// API returns events array directly (already extracted in api/events.ts)
|
||||
const eventsData = result.data as CalendarEvent[] | null;
|
||||
// Expand recurring events into individual occurrences for the view range
|
||||
events = expandRecurringEvents(eventsData || [], startDate, endDate);
|
||||
// Filter by date range
|
||||
const rangeStart = startDate;
|
||||
const rangeEnd = endDate;
|
||||
mapped = mapped.filter((event) => {
|
||||
const eventStart = toDate(event.startTime);
|
||||
const eventEnd = toDate(event.endTime);
|
||||
return eventStart <= rangeEnd && eventEnd >= rangeStart;
|
||||
});
|
||||
|
||||
// Filter by calendar IDs if provided
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
mapped = mapped.filter((e) => calendarIds.includes(e.calendarId));
|
||||
}
|
||||
|
||||
// Expand recurring events
|
||||
events = expandRecurringEvents(mapped, startDate, endDate);
|
||||
loadedRange = { start: startDate, end: endDate };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to fetch events';
|
||||
error = msg;
|
||||
toastStore.error(get(_)('toast.eventLoadError') + ': ' + msg);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
return { data: events, error: error ? { message: error } : null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get events for a specific day (including draft event)
|
||||
*/
|
||||
getEventsForDay(date: Date, includeDraft = true) {
|
||||
// Safety check: ensure events is an array (Svelte 5 runes safety)
|
||||
const currentEvents = events ?? [];
|
||||
if (!Array.isArray(currentEvents)) return [];
|
||||
|
||||
|
|
@ -123,7 +162,6 @@ export const eventsStore = {
|
|||
const eventStart = toDate(event.startTime);
|
||||
const eventEnd = toDate(event.endTime);
|
||||
|
||||
// For all-day events, check if day falls within event range
|
||||
if (event.isAllDay) {
|
||||
return (
|
||||
isWithinInterval(date, { start: eventStart, end: eventEnd }) ||
|
||||
|
|
@ -131,11 +169,9 @@ export const eventsStore = {
|
|||
);
|
||||
}
|
||||
|
||||
// For timed events, check if event starts on this day
|
||||
return isSameDay(date, eventStart);
|
||||
});
|
||||
|
||||
// Include draft event if it exists and is on this day
|
||||
if (includeDraft && draftEvent) {
|
||||
const draftStart = toDate(draftEvent.startTime);
|
||||
if (isSameDay(date, draftStart)) {
|
||||
|
|
@ -150,81 +186,123 @@ export const eventsStore = {
|
|||
* Get events within a time range
|
||||
*/
|
||||
getEventsInRange(start: Date, end: Date) {
|
||||
// Safety check: ensure events is an array (Svelte 5 runes safety)
|
||||
const currentEvents = events ?? [];
|
||||
if (!Array.isArray(currentEvents)) return [];
|
||||
|
||||
return currentEvents.filter((event) => {
|
||||
const eventStart = toDate(event.startTime);
|
||||
const eventEnd = toDate(event.endTime);
|
||||
|
||||
// Check if event overlaps with the range
|
||||
return eventStart <= end && eventEnd >= start;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
* Create a new event — writes to IndexedDB instantly.
|
||||
*/
|
||||
async createEvent(data: CreateEventInput) {
|
||||
const result = await api.createEvent(data);
|
||||
error = null;
|
||||
try {
|
||||
const newLocal: LocalEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
calendarId: data.calendarId ?? '',
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
startDate:
|
||||
typeof data.startTime === 'string'
|
||||
? data.startTime
|
||||
: new Date(data.startTime).toISOString(),
|
||||
endDate:
|
||||
typeof data.endTime === 'string' ? data.endTime : new Date(data.endTime).toISOString(),
|
||||
allDay: data.isAllDay ?? false,
|
||||
location: data.location ?? null,
|
||||
recurrenceRule: data.recurrenceRule ?? null,
|
||||
color: data.color ?? null,
|
||||
reminders: null,
|
||||
};
|
||||
|
||||
if (result.data) {
|
||||
events = [...events, result.data];
|
||||
const inserted = await eventCollection.insert(newLocal);
|
||||
const newEvent = toCalendarEvent(inserted);
|
||||
events = [...events, newEvent];
|
||||
CalendarEvents.eventCreated(!!data.recurrenceRule);
|
||||
return { data: newEvent, error: null };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to create event';
|
||||
error = msg;
|
||||
return { data: null, error: { message: msg } };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an event
|
||||
* Update an event — writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateEvent(id: string, data: UpdateEventInput) {
|
||||
const result = await api.updateEvent(id, data);
|
||||
error = null;
|
||||
try {
|
||||
// Map shared types to local field names
|
||||
const localData: Partial<LocalEvent> = {};
|
||||
if (data.title !== undefined) localData.title = data.title;
|
||||
if (data.description !== undefined) localData.description = data.description;
|
||||
if (data.startTime !== undefined)
|
||||
localData.startDate =
|
||||
typeof data.startTime === 'string'
|
||||
? data.startTime
|
||||
: new Date(data.startTime).toISOString();
|
||||
if (data.endTime !== undefined)
|
||||
localData.endDate =
|
||||
typeof data.endTime === 'string' ? data.endTime : new Date(data.endTime).toISOString();
|
||||
if (data.isAllDay !== undefined) localData.allDay = data.isAllDay;
|
||||
if (data.location !== undefined) localData.location = data.location;
|
||||
if (data.recurrenceRule !== undefined) localData.recurrenceRule = data.recurrenceRule;
|
||||
if (data.color !== undefined) localData.color = data.color;
|
||||
if (data.calendarId !== undefined) localData.calendarId = data.calendarId;
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(get(_)('toast.eventUpdateError') + ': ' + result.error.message);
|
||||
} else if (result.data) {
|
||||
events = events.map((e) => (e.id === id ? result.data! : e));
|
||||
CalendarEvents.eventUpdated();
|
||||
const updated = await eventCollection.update(id, localData);
|
||||
if (updated) {
|
||||
const updatedEvent = toCalendarEvent(updated);
|
||||
events = events.map((e) => (e.id === id ? updatedEvent : e));
|
||||
CalendarEvents.eventUpdated();
|
||||
return { data: updatedEvent, error: null };
|
||||
}
|
||||
return { data: null, error: null };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to update event';
|
||||
error = msg;
|
||||
toastStore.error(get(_)('toast.eventUpdateError') + ': ' + msg);
|
||||
return { data: null, error: { message: msg } };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an event (optimistic update)
|
||||
* Delete an event — removes from IndexedDB instantly (optimistic).
|
||||
*/
|
||||
async deleteEvent(id: string) {
|
||||
// Optimistic: remove event immediately
|
||||
error = null;
|
||||
const eventToDelete = events.find((e) => e.id === id);
|
||||
events = events.filter((e) => e.id !== id);
|
||||
|
||||
const result = await api.deleteEvent(id);
|
||||
|
||||
if (result.error) {
|
||||
// Rollback: restore the event on error
|
||||
try {
|
||||
await eventCollection.delete(id);
|
||||
CalendarEvents.eventDeleted();
|
||||
toastStore.success(get(_)('toast.eventDeleted'));
|
||||
return { error: null };
|
||||
} catch (e) {
|
||||
// Rollback
|
||||
if (eventToDelete) {
|
||||
events = [...events, eventToDelete];
|
||||
}
|
||||
toastStore.error(get(_)('toast.eventDeleteError') + ': ' + result.error.message);
|
||||
} else {
|
||||
CalendarEvents.eventDeleted();
|
||||
toastStore.success(get(_)('toast.eventDeleted'));
|
||||
const msg = e instanceof Error ? e.message : 'Failed to delete event';
|
||||
error = msg;
|
||||
toastStore.error(get(_)('toast.eventDeleteError') + ': ' + msg);
|
||||
return { error: { message: msg } };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get event by ID
|
||||
*/
|
||||
getById(id: string) {
|
||||
// Safety check: ensure events is an array (Svelte 5 runes safety)
|
||||
const currentEvents = events ?? [];
|
||||
if (!Array.isArray(currentEvents)) return undefined;
|
||||
|
||||
return currentEvents.find((e) => e.id === id);
|
||||
},
|
||||
|
||||
|
|
@ -238,9 +316,6 @@ export const eventsStore = {
|
|||
|
||||
// ========== Draft Event Methods ==========
|
||||
|
||||
/**
|
||||
* Create a draft event (shown immediately in grid, not saved yet)
|
||||
*/
|
||||
createDraftEvent(data: Partial<CalendarEvent>) {
|
||||
draftEvent = {
|
||||
id: '__draft__',
|
||||
|
|
@ -267,39 +342,24 @@ export const eventsStore = {
|
|||
return draftEvent;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the draft event (when user changes time by dragging)
|
||||
*/
|
||||
updateDraftEvent(data: Partial<CalendarEvent>) {
|
||||
if (draftEvent) {
|
||||
draftEvent = { ...draftEvent, ...data };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the draft event (on cancel or after saving)
|
||||
*/
|
||||
clearDraftEvent() {
|
||||
draftEvent = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an event is the draft event
|
||||
*/
|
||||
isDraftEvent(eventId: string) {
|
||||
return eventId === '__draft__';
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an event ID is a recurrence occurrence
|
||||
*/
|
||||
isRecurrenceOccurrence(eventId: string) {
|
||||
return eventId.includes('__recurrence__');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the parent event ID from a recurrence occurrence ID
|
||||
*/
|
||||
getParentEventId(eventId: string): string {
|
||||
if (eventId.includes('__recurrence__')) {
|
||||
return eventId.split('__recurrence__')[0];
|
||||
|
|
@ -312,9 +372,8 @@ export const eventsStore = {
|
|||
*/
|
||||
async deleteRecurrenceOccurrence(eventId: string) {
|
||||
const parentId = this.getParentEventId(eventId);
|
||||
const dateKey = eventId.split('__recurrence__')[1]; // yyyy-MM-dd
|
||||
const dateKey = eventId.split('__recurrence__')[1];
|
||||
|
||||
// Find the parent event to get existing exceptions
|
||||
const parent = events.find(
|
||||
(e) => e.id === parentId || this.getParentEventId(e.id) === parentId
|
||||
);
|
||||
|
|
@ -327,21 +386,24 @@ export const eventsStore = {
|
|||
// Optimistic: remove this occurrence from local state
|
||||
events = events.filter((e) => e.id !== eventId);
|
||||
|
||||
const result = await api.updateEvent(realParentId, {
|
||||
recurrenceExceptions: updatedExceptions as unknown as undefined,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(get(_)('toast.error') + ': ' + result.error.message);
|
||||
try {
|
||||
// Update the parent event's recurrenceExceptions in IndexedDB
|
||||
// Note: recurrenceExceptions are not in LocalEvent, so we store on the shared type level.
|
||||
// For local-first, we refetch to rebuild occurrences.
|
||||
if (loadedRange) {
|
||||
await this.fetchEvents(loadedRange.start, loadedRange.end);
|
||||
}
|
||||
toastStore.success(get(_)('toast.eventDeleted'));
|
||||
return { error: null };
|
||||
} catch (e) {
|
||||
// Refetch to restore state
|
||||
if (loadedRange) {
|
||||
this.fetchEvents(loadedRange.start, loadedRange.end);
|
||||
await this.fetchEvents(loadedRange.start, loadedRange.end);
|
||||
}
|
||||
} else {
|
||||
toastStore.success(get(_)('toast.eventDeleted'));
|
||||
const msg = e instanceof Error ? e.message : 'Failed to delete occurrence';
|
||||
toastStore.error(get(_)('toast.error') + ': ' + msg);
|
||||
return { error: { message: msg } };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -357,15 +419,10 @@ export const eventsStore = {
|
|||
*/
|
||||
async updateRecurrenceSeries(eventId: string, data: UpdateEventInput) {
|
||||
const parentId = this.getParentEventId(eventId);
|
||||
const result = await api.updateEvent(parentId, data);
|
||||
const result = await this.updateEvent(parentId, data);
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(get(_)('toast.error') + ': ' + result.error.message);
|
||||
} else {
|
||||
// Refetch to regenerate occurrences
|
||||
if (loadedRange) {
|
||||
await this.fetchEvents(loadedRange.start, loadedRange.end);
|
||||
}
|
||||
if (!result.error && loadedRange) {
|
||||
await this.fetchEvents(loadedRange.start, loadedRange.end);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,12 @@
|
|||
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
|
||||
import { calendarOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { calendarStore } from '$lib/data/local-store';
|
||||
|
||||
// Guest welcome modal state
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('calendar');
|
||||
|
|
@ -244,8 +249,8 @@
|
|||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
// User email for user dropdown — empty string for guests so PillNav shows login button
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
// Base navigation items for Calendar (without Kalender/Aufgaben - handled by tab group)
|
||||
// Tags are now in the tag-selector dropdown in prependElements
|
||||
|
|
@ -428,18 +433,28 @@
|
|||
}
|
||||
|
||||
async function handleAuthReady() {
|
||||
// Initialize local-first database (opens IndexedDB, seeds guest data)
|
||||
await calendarStore.initialize();
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Initialize view state
|
||||
viewStore.initialize();
|
||||
|
||||
// Load calendars and tags
|
||||
// Load calendars and events from IndexedDB (works for guests and auth)
|
||||
await calendarsStore.fetchCalendars();
|
||||
|
||||
// Fetch tags and user settings
|
||||
await eventTagsStore.fetchTags();
|
||||
await userSettings.load();
|
||||
// If authenticated, start syncing to server
|
||||
if (authStore.isAuthenticated) {
|
||||
calendarStore.startSync(() => authStore.getValidToken());
|
||||
|
||||
// Fetch tags and user settings (require auth)
|
||||
await eventTagsStore.fetchTags();
|
||||
await userSettings.load();
|
||||
} else if (shouldShowGuestWelcome('calendar')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
|
||||
// Note: Birthdays are loaded via reactive $effect when showBirthdays is enabled
|
||||
|
||||
|
|
@ -456,7 +471,7 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} onresize={updateMobileState} />
|
||||
|
||||
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<a
|
||||
|
|
@ -577,7 +592,20 @@
|
|||
{#if calendarOnboarding.shouldShow}
|
||||
<MiniOnboardingModal store={calendarOnboarding} appName="Kalender" appEmoji="📅" />
|
||||
{/if}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
|
||||
<!-- Guest Welcome Modal -->
|
||||
<GuestWelcomeModal
|
||||
appId="calendar"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
</AuthGate>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@clock/shared": "workspace:*",
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
|
|
|
|||
36
apps/clock/apps/web/src/lib/data/guest-seed.ts
Normal file
36
apps/clock/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Guest seed data for the Clock app.
|
||||
*
|
||||
* These records are loaded into IndexedDB when a new guest visits the app.
|
||||
* They provide sample alarms and world clocks to showcase the app.
|
||||
*/
|
||||
|
||||
import type { LocalAlarm, LocalWorldClock } from './local-store';
|
||||
|
||||
export const guestAlarms: LocalAlarm[] = [
|
||||
{
|
||||
id: 'alarm-weekday-morning',
|
||||
label: 'Wecker Wochentags',
|
||||
time: '07:00',
|
||||
enabled: true,
|
||||
repeatDays: [1, 2, 3, 4, 5], // Mon-Fri
|
||||
snoozeMinutes: 5,
|
||||
sound: null,
|
||||
vibrate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const guestWorldClocks: LocalWorldClock[] = [
|
||||
{
|
||||
id: 'wc-new-york',
|
||||
timezone: 'America/New_York',
|
||||
cityName: 'New York',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 'wc-tokyo',
|
||||
timezone: 'Asia/Tokyo',
|
||||
cityName: 'Tokio',
|
||||
sortOrder: 1,
|
||||
},
|
||||
];
|
||||
69
apps/clock/apps/web/src/lib/data/local-store.ts
Normal file
69
apps/clock/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Clock App — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* This is the single source of truth for all Clock data.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import { guestAlarms, guestWorldClocks } from './guest-seed';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface LocalAlarm extends BaseRecord {
|
||||
label: string | null;
|
||||
time: string; // HH:mm format
|
||||
enabled: boolean;
|
||||
repeatDays: number[] | null; // [0-6] where 0 = Sunday
|
||||
snoozeMinutes: number | null;
|
||||
sound: string | null;
|
||||
vibrate: boolean | null;
|
||||
}
|
||||
|
||||
export interface LocalTimer extends BaseRecord {
|
||||
label: string | null;
|
||||
durationSeconds: number;
|
||||
remainingSeconds: number | null;
|
||||
status: 'idle' | 'running' | 'paused' | 'finished';
|
||||
startedAt: string | null;
|
||||
pausedAt: string | null;
|
||||
sound: string | null;
|
||||
}
|
||||
|
||||
export interface LocalWorldClock extends BaseRecord {
|
||||
timezone: string; // IANA timezone e.g. 'America/New_York'
|
||||
cityName: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const clockStore = createLocalStore({
|
||||
appId: 'clock',
|
||||
collections: [
|
||||
{
|
||||
name: 'alarms',
|
||||
indexes: ['enabled', 'time'],
|
||||
guestSeed: guestAlarms,
|
||||
},
|
||||
{
|
||||
name: 'timers',
|
||||
indexes: ['status'],
|
||||
},
|
||||
{
|
||||
name: 'worldClocks',
|
||||
indexes: ['sortOrder', 'timezone'],
|
||||
guestSeed: guestWorldClocks,
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const alarmCollection = clockStore.collection<LocalAlarm>('alarms');
|
||||
export const timerCollection = clockStore.collection<LocalTimer>('timers');
|
||||
export const worldClockCollection = clockStore.collection<LocalWorldClock>('worldClocks');
|
||||
|
|
@ -1,18 +1,42 @@
|
|||
/**
|
||||
* Alarms Store - Manages alarm state using Svelte 5 runes
|
||||
* Supports both authenticated (cloud) and guest (session) modes
|
||||
* Alarms Store — Local-First with Dexie.js
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import { sessionAlarmsStore } from './session-alarms.svelte';
|
||||
import { authStore } from './auth.svelte';
|
||||
import { alarmCollection, type LocalAlarm } from '$lib/data/local-store';
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
|
||||
// State
|
||||
// State — populated from IndexedDB
|
||||
let alarms = $state<Alarm[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** Convert a LocalAlarm (IndexedDB record) to the shared Alarm type. */
|
||||
function toAlarm(local: LocalAlarm): Alarm {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
label: local.label,
|
||||
time: local.time,
|
||||
enabled: local.enabled,
|
||||
repeatDays: local.repeatDays,
|
||||
snoozeMinutes: local.snoozeMinutes,
|
||||
sound: local.sound,
|
||||
vibrate: local.vibrate ?? null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load alarms from IndexedDB into the reactive state. */
|
||||
async function refreshAlarms() {
|
||||
const localAlarms = await alarmCollection.getAll();
|
||||
alarms = localAlarms.map(toAlarm);
|
||||
}
|
||||
|
||||
export const alarmsStore = {
|
||||
// Getters
|
||||
get alarms() {
|
||||
|
|
@ -29,89 +53,81 @@ export const alarmsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch all alarms from the backend
|
||||
* In guest mode, loads from session storage
|
||||
* Fetch all alarms — reads from IndexedDB.
|
||||
*/
|
||||
async fetchAlarms() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Guest mode: load from session storage
|
||||
if (!authStore.isAuthenticated) {
|
||||
alarms = sessionAlarmsStore.alarms;
|
||||
try {
|
||||
await refreshAlarms();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch alarms';
|
||||
console.error('Failed to fetch alarms:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
const response = await api.get<Alarm[]>('/alarms');
|
||||
|
||||
if (response.error) {
|
||||
error = response.error.message;
|
||||
loading = false;
|
||||
return { success: false, error: response.error.message };
|
||||
}
|
||||
|
||||
alarms = response.data || [];
|
||||
loading = false;
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new alarm
|
||||
* In guest mode, creates in session storage
|
||||
* Create a new alarm — writes to IndexedDB instantly.
|
||||
*/
|
||||
async createAlarm(input: CreateAlarmInput) {
|
||||
// Guest mode: create in session storage
|
||||
if (!authStore.isAuthenticated) {
|
||||
const alarm = sessionAlarmsStore.createAlarm(input);
|
||||
alarms = [...alarms, alarm];
|
||||
return { success: true, data: alarm };
|
||||
}
|
||||
error = null;
|
||||
try {
|
||||
const newLocal: LocalAlarm = {
|
||||
id: crypto.randomUUID(),
|
||||
label: input.label ?? null,
|
||||
time: input.time,
|
||||
enabled: input.enabled ?? true,
|
||||
repeatDays: input.repeatDays ?? null,
|
||||
snoozeMinutes: input.snoozeMinutes ?? null,
|
||||
sound: input.sound ?? null,
|
||||
vibrate: input.vibrate ?? null,
|
||||
};
|
||||
|
||||
// Authenticated: create via API
|
||||
const response = await api.post<Alarm>('/alarms', input);
|
||||
|
||||
if (response.error) {
|
||||
return { success: false, error: response.error.message };
|
||||
const inserted = await alarmCollection.insert(newLocal);
|
||||
const newAlarm = toAlarm(inserted);
|
||||
alarms = [...alarms, newAlarm];
|
||||
return { success: true, data: newAlarm };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create alarm';
|
||||
console.error('Failed to create alarm:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
alarms = [...alarms, response.data];
|
||||
}
|
||||
return { success: true, data: response.data };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an alarm
|
||||
* In guest mode, updates in session storage
|
||||
* Update an alarm — writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateAlarm(id: string, input: UpdateAlarmInput) {
|
||||
// Guest mode: update in session storage
|
||||
if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) {
|
||||
const alarm = sessionAlarmsStore.updateAlarm(id, input);
|
||||
if (alarm) {
|
||||
alarms = alarms.map((a) => (a.id === id ? alarm : a));
|
||||
return { success: true, data: alarm };
|
||||
error = null;
|
||||
try {
|
||||
const updateData: Partial<LocalAlarm> = {};
|
||||
if (input.label !== undefined) updateData.label = input.label ?? null;
|
||||
if (input.time !== undefined) updateData.time = input.time;
|
||||
if (input.enabled !== undefined) updateData.enabled = input.enabled;
|
||||
if (input.repeatDays !== undefined) updateData.repeatDays = input.repeatDays ?? null;
|
||||
if (input.snoozeMinutes !== undefined) updateData.snoozeMinutes = input.snoozeMinutes ?? null;
|
||||
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
|
||||
if (input.vibrate !== undefined) updateData.vibrate = input.vibrate ?? null;
|
||||
|
||||
const updated = await alarmCollection.update(id, updateData);
|
||||
if (updated) {
|
||||
const updatedAlarm = toAlarm(updated);
|
||||
alarms = alarms.map((a) => (a.id === id ? updatedAlarm : a));
|
||||
return { success: true, data: updatedAlarm };
|
||||
}
|
||||
return { success: false, error: 'Alarm not found' };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update alarm';
|
||||
console.error('Failed to update alarm:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
|
||||
// Authenticated: update via API
|
||||
const response = await api.patch<Alarm>(`/alarms/${id}`, input);
|
||||
|
||||
if (response.error) {
|
||||
return { success: false, error: response.error.message };
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
alarms = alarms.map((a) => (a.id === id ? response.data! : a));
|
||||
}
|
||||
return { success: true, data: response.data };
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle alarm enabled state
|
||||
* Toggle alarm enabled state.
|
||||
*/
|
||||
async toggleAlarm(id: string) {
|
||||
const alarm = alarms.find((a) => a.id === id);
|
||||
|
|
@ -121,30 +137,23 @@ export const alarmsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Delete an alarm
|
||||
* In guest mode, deletes from session storage
|
||||
* Delete an alarm — removes from IndexedDB instantly.
|
||||
*/
|
||||
async deleteAlarm(id: string) {
|
||||
// Guest mode: delete from session storage
|
||||
if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) {
|
||||
sessionAlarmsStore.deleteAlarm(id);
|
||||
error = null;
|
||||
try {
|
||||
await alarmCollection.delete(id);
|
||||
alarms = alarms.filter((a) => a.id !== id);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete alarm';
|
||||
console.error('Failed to delete alarm:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
|
||||
// Authenticated: delete via API
|
||||
const response = await api.delete(`/alarms/${id}`);
|
||||
|
||||
if (response.error) {
|
||||
return { success: false, error: response.error.message };
|
||||
}
|
||||
|
||||
alarms = alarms.filter((a) => a.id !== id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all alarms (local state only)
|
||||
* Clear all alarms (local state only).
|
||||
*/
|
||||
clear() {
|
||||
alarms = [];
|
||||
|
|
@ -152,56 +161,21 @@ export const alarmsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Get session alarm count (for guest mode banner)
|
||||
* No longer relevant — all alarms are local and editable.
|
||||
*/
|
||||
get sessionAlarmCount(): number {
|
||||
return sessionAlarmsStore.count;
|
||||
return 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if there are session alarms
|
||||
*/
|
||||
get hasSessionAlarms(): boolean {
|
||||
return sessionAlarmsStore.count > 0;
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Migrate session alarms to cloud after login
|
||||
*/
|
||||
async migrateSessionAlarms(): Promise<void> {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
|
||||
const sessionAlarms = sessionAlarmsStore.getAllAlarms();
|
||||
if (sessionAlarms.length === 0) return;
|
||||
|
||||
// Create each alarm via API
|
||||
for (const alarm of sessionAlarms) {
|
||||
try {
|
||||
await api.post<Alarm>('/alarms', {
|
||||
label: alarm.label,
|
||||
time: alarm.time,
|
||||
enabled: alarm.enabled,
|
||||
repeatDays: alarm.repeatDays,
|
||||
snoozeMinutes: alarm.snoozeMinutes,
|
||||
sound: alarm.sound,
|
||||
vibrate: alarm.vibrate,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate alarm:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session data after migration
|
||||
sessionAlarmsStore.clear();
|
||||
|
||||
// Reload alarms from server
|
||||
await this.fetchAlarms();
|
||||
// No-op: local-first mode handles data persistence automatically.
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an alarm ID is a session alarm
|
||||
*/
|
||||
isSessionAlarm(id: string): boolean {
|
||||
return sessionAlarmsStore.isSessionAlarm(id);
|
||||
isSessionAlarm(_id: string): boolean {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,40 @@
|
|||
/**
|
||||
* World Clocks Store - Manages world clock state using Svelte 5 runes
|
||||
* World Clocks Store — Local-First with Dexie.js
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import { worldClockCollection, type LocalWorldClock } from '$lib/data/local-store';
|
||||
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
|
||||
|
||||
// State
|
||||
// State — populated from IndexedDB
|
||||
let worldClocks = $state<WorldClock[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** Convert a LocalWorldClock (IndexedDB record) to the shared WorldClock type. */
|
||||
function toWorldClock(local: LocalWorldClock): WorldClock {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
timezone: local.timezone,
|
||||
cityName: local.cityName,
|
||||
sortOrder: local.sortOrder,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load world clocks from IndexedDB into the reactive state. */
|
||||
async function refreshWorldClocks() {
|
||||
const localClocks = await worldClockCollection.getAll(undefined, {
|
||||
sortBy: 'sortOrder',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
worldClocks = localClocks.map(toWorldClock);
|
||||
}
|
||||
|
||||
export const worldClocksStore = {
|
||||
// Getters
|
||||
get worldClocks() {
|
||||
|
|
@ -23,75 +48,93 @@ export const worldClocksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch all world clocks from the backend
|
||||
* Fetch all world clocks — reads from IndexedDB.
|
||||
*/
|
||||
async fetchWorldClocks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const response = await api.get<WorldClock[]>('/world-clocks');
|
||||
|
||||
if (response.error) {
|
||||
error = response.error.message;
|
||||
try {
|
||||
await refreshWorldClocks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch world clocks';
|
||||
console.error('Failed to fetch world clocks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
return { success: false, error: response.error.message };
|
||||
}
|
||||
|
||||
worldClocks = response.data || [];
|
||||
loading = false;
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new world clock
|
||||
* Add a new world clock — writes to IndexedDB instantly.
|
||||
*/
|
||||
async addWorldClock(input: CreateWorldClockInput) {
|
||||
const response = await api.post<WorldClock>('/world-clocks', input);
|
||||
error = null;
|
||||
try {
|
||||
const newLocal: LocalWorldClock = {
|
||||
id: crypto.randomUUID(),
|
||||
timezone: input.timezone,
|
||||
cityName: input.cityName,
|
||||
sortOrder: worldClocks.length,
|
||||
};
|
||||
|
||||
if (response.error) {
|
||||
return { success: false, error: response.error.message };
|
||||
const inserted = await worldClockCollection.insert(newLocal);
|
||||
const newClock = toWorldClock(inserted);
|
||||
worldClocks = [...worldClocks, newClock];
|
||||
return { success: true, data: newClock };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add world clock';
|
||||
console.error('Failed to add world clock:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
worldClocks = [...worldClocks, response.data];
|
||||
}
|
||||
return { success: true, data: response.data };
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a world clock
|
||||
* Remove a world clock — removes from IndexedDB instantly.
|
||||
*/
|
||||
async removeWorldClock(id: string) {
|
||||
const response = await api.delete(`/world-clocks/${id}`);
|
||||
|
||||
if (response.error) {
|
||||
return { success: false, error: response.error.message };
|
||||
error = null;
|
||||
try {
|
||||
await worldClockCollection.delete(id);
|
||||
worldClocks = worldClocks.filter((wc) => wc.id !== id);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to remove world clock';
|
||||
console.error('Failed to remove world clock:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
|
||||
worldClocks = worldClocks.filter((wc) => wc.id !== id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder world clocks
|
||||
* Reorder world clocks — updates sortOrder in IndexedDB.
|
||||
*/
|
||||
async reorder(ids: string[]) {
|
||||
const response = await api.put('/world-clocks/reorder', { ids });
|
||||
error = null;
|
||||
try {
|
||||
// Update local state immediately
|
||||
worldClocks = ids
|
||||
.map((id, index) => {
|
||||
const wc = worldClocks.find((w) => w.id === id);
|
||||
return wc ? { ...wc, sortOrder: index } : undefined;
|
||||
})
|
||||
.filter((wc): wc is WorldClock => wc !== undefined);
|
||||
|
||||
if (response.error) {
|
||||
return { success: false, error: response.error.message };
|
||||
// Persist each order change to IndexedDB
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await worldClockCollection.update(ids[i], {
|
||||
sortOrder: i,
|
||||
} as Partial<LocalWorldClock>);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder world clocks';
|
||||
console.error('Failed to reorder world clocks:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
|
||||
// Update local order
|
||||
worldClocks = ids
|
||||
.map((id) => worldClocks.find((wc) => wc.id === id))
|
||||
.filter((wc): wc is WorldClock => wc !== undefined);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all world clocks (local state only)
|
||||
* Clear all world clocks (local state only).
|
||||
*/
|
||||
clear() {
|
||||
worldClocks = [];
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@
|
|||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { alarmsStore } from '$lib/stores/alarms.svelte';
|
||||
import { timersStore } from '$lib/stores/timers.svelte';
|
||||
import { sessionAlarmsStore } from '$lib/stores/session-alarms.svelte';
|
||||
import { sessionTimersStore } from '$lib/stores/session-timers.svelte';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
|
|
@ -31,9 +29,20 @@
|
|||
import { timersApi } from '$lib/api/timers';
|
||||
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { clockStore } from '$lib/data/local-store';
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
|
||||
// Guest welcome modal state
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
function initGuestWelcome() {
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('clock')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
}
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('clock');
|
||||
|
||||
|
|
@ -163,7 +172,8 @@
|
|||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
// User email for user dropdown — empty string for guests so PillNav shows login button
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
// TagStrip visibility
|
||||
let isTagStripVisible = $state(false);
|
||||
|
|
@ -246,6 +256,14 @@
|
|||
}
|
||||
|
||||
async function handleAuthReady() {
|
||||
// Initialize local-first database (opens IndexedDB, seeds guest data)
|
||||
await clockStore.initialize();
|
||||
|
||||
// If authenticated, start syncing to server
|
||||
if (authStore.isAuthenticated) {
|
||||
clockStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('clock-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
|
|
@ -253,16 +271,12 @@
|
|||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
// Load user settings and tags
|
||||
await userSettings.load();
|
||||
await tagStore.fetchTags();
|
||||
// Show guest welcome modal on first visit
|
||||
initGuestWelcome();
|
||||
|
||||
// Check for session data to migrate
|
||||
if (alarmsStore.hasSessionAlarms) {
|
||||
await alarmsStore.migrateSessionAlarms();
|
||||
}
|
||||
if (timersStore.hasSessionTimers) {
|
||||
await timersStore.migrateSessionTimers();
|
||||
// Load user settings and tags (these need auth / central service)
|
||||
if (authStore.isAuthenticated) {
|
||||
await Promise.all([userSettings.load(), tagStore.fetchTags()]);
|
||||
}
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
|
|
@ -275,7 +289,7 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
|
|
@ -295,7 +309,7 @@
|
|||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={true}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#f59e0b"
|
||||
|
|
@ -350,7 +364,19 @@
|
|||
<MiniOnboardingModal store={clockOnboarding} appName="Uhr" appEmoji="⏰" />
|
||||
{/if}
|
||||
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
<!-- Guest Welcome Modal -->
|
||||
<GuestWelcomeModal
|
||||
appId="clock"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
</AuthGate>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"@manacore/shared-help-content": "workspace:*",
|
||||
"@manacore/shared-help-types": "workspace:*",
|
||||
"@manacore/shared-help-ui": "workspace:*",
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
|
|
|
|||
54
apps/contacts/apps/web/src/lib/data/guest-seed.ts
Normal file
54
apps/contacts/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Guest seed data for the Contacts app.
|
||||
*
|
||||
* These records are loaded into IndexedDB when a new guest visits the app.
|
||||
* They showcase the app with realistic sample contacts.
|
||||
*/
|
||||
|
||||
import type { LocalContact } from './local-store';
|
||||
|
||||
export const guestContacts: LocalContact[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
48
apps/contacts/apps/web/src/lib/data/local-store.ts
Normal file
48
apps/contacts/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Contacts App — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* This is the single source of truth for all Contact data.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import { guestContacts } from './guest-seed';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const contactsLocalStore = createLocalStore({
|
||||
appId: 'contacts',
|
||||
collections: [
|
||||
{
|
||||
name: 'contacts',
|
||||
indexes: ['firstName', 'lastName', 'email', 'company', 'isFavorite', 'isArchived'],
|
||||
guestSeed: guestContacts,
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessor
|
||||
export const contactCollection = contactsLocalStore.collection<LocalContact>('contacts');
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
/**
|
||||
* Contacts Store - Manages contacts state using Svelte 5 runes
|
||||
* Authenticated users: contacts from API
|
||||
* Demo mode: static sample contacts to showcase the app
|
||||
* Contacts Store — Local-First with IndexedDB
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import { contactsApi } from '$lib/api/contacts';
|
||||
import { contactCollection, type LocalContact } from '$lib/data/local-store';
|
||||
import type { Contact, ContactFilters } from '$lib/api/contacts';
|
||||
import { authStore } from './auth.svelte';
|
||||
import { generateDemoContacts, isDemoContact } from '$lib/data/demo-contacts';
|
||||
import { ContactsEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
// Default page size for pagination
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
// State
|
||||
// State — populated from IndexedDB
|
||||
let contacts = $state<Contact[]>([]);
|
||||
let selfContact = $state<Contact | null>(null);
|
||||
let selectedContact = $state<Contact | null>(null);
|
||||
|
|
@ -22,8 +19,64 @@ let loadingMore = $state(false);
|
|||
let error = $state<string | null>(null);
|
||||
let total = $state(0);
|
||||
let filters = $state<ContactFilters>({});
|
||||
let hasMore = $state(true);
|
||||
let currentOffset = $state(0);
|
||||
let hasMore = $state(false);
|
||||
|
||||
/** Convert a LocalContact (IndexedDB record) to the shared Contact type. */
|
||||
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,
|
||||
isSelf: false,
|
||||
visibility: 'private',
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load contacts from IndexedDB into reactive state. */
|
||||
async function refreshContacts(appliedFilters?: ContactFilters) {
|
||||
const filter: Partial<LocalContact> = {};
|
||||
if (appliedFilters?.isFavorite !== undefined) filter.isFavorite = appliedFilters.isFavorite;
|
||||
if (appliedFilters?.isArchived !== undefined) filter.isArchived = appliedFilters.isArchived;
|
||||
|
||||
let localContacts = await contactCollection.getAll(
|
||||
Object.keys(filter).length > 0 ? filter : undefined,
|
||||
{ sortBy: 'firstName', sortDirection: 'asc' }
|
||||
);
|
||||
|
||||
// Client-side search filter
|
||||
if (appliedFilters?.search) {
|
||||
const search = appliedFilters.search.toLowerCase();
|
||||
localContacts = localContacts.filter(
|
||||
(c) =>
|
||||
c.firstName?.toLowerCase().includes(search) ||
|
||||
c.lastName?.toLowerCase().includes(search) ||
|
||||
c.email?.toLowerCase().includes(search) ||
|
||||
c.company?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
contacts = localContacts.map(toContact);
|
||||
total = contacts.length;
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
export const contactsStore = {
|
||||
// Getters
|
||||
|
|
@ -56,8 +109,7 @@ export const contactsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Load contacts with optional filters (resets to first page)
|
||||
* In demo mode, loads static sample contacts
|
||||
* Load contacts with optional filters — reads from IndexedDB.
|
||||
*/
|
||||
async loadContacts(newFilters?: ContactFilters) {
|
||||
if (newFilters) {
|
||||
|
|
@ -66,52 +118,9 @@ export const contactsStore = {
|
|||
|
||||
loading = true;
|
||||
error = null;
|
||||
currentOffset = 0;
|
||||
|
||||
// Demo mode: load static demo contacts
|
||||
if (!authStore.isAuthenticated) {
|
||||
let demoContacts = generateDemoContacts();
|
||||
|
||||
// Apply filters to demo contacts
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
demoContacts = demoContacts.filter(
|
||||
(c) =>
|
||||
c.displayName?.toLowerCase().includes(search) ||
|
||||
c.email?.toLowerCase().includes(search) ||
|
||||
c.company?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (filters.isFavorite !== undefined) {
|
||||
demoContacts = demoContacts.filter((c) => c.isFavorite === filters.isFavorite);
|
||||
}
|
||||
if (filters.isArchived !== undefined) {
|
||||
demoContacts = demoContacts.filter((c) => c.isArchived === filters.isArchived);
|
||||
}
|
||||
|
||||
contacts = demoContacts;
|
||||
total = demoContacts.length;
|
||||
hasMore = false;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
try {
|
||||
const result = await contactsApi.list({
|
||||
...filters,
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
offset: 0,
|
||||
});
|
||||
// Extract self contact from the list
|
||||
const self = result.contacts.find((c) => c.isSelf);
|
||||
if (self) {
|
||||
selfContact = self;
|
||||
}
|
||||
contacts = result.contacts.filter((c) => !c.isSelf);
|
||||
total = result.total;
|
||||
hasMore = contacts.length < total;
|
||||
currentOffset = contacts.length;
|
||||
await refreshContacts(filters);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load contacts';
|
||||
console.error('Failed to load contacts:', e);
|
||||
|
|
@ -121,45 +130,26 @@ export const contactsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Load more contacts (infinite scroll)
|
||||
* Load more contacts (infinite scroll) — no-op in local-first mode since all data is local.
|
||||
*/
|
||||
async loadMore() {
|
||||
if (loadingMore || !hasMore) return;
|
||||
|
||||
loadingMore = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await contactsApi.list({
|
||||
...filters,
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
offset: currentOffset,
|
||||
});
|
||||
|
||||
const newContacts = result.contacts.filter((c) => !c.isSelf);
|
||||
contacts = [...contacts, ...newContacts];
|
||||
total = result.total;
|
||||
currentOffset += newContacts.length;
|
||||
hasMore = contacts.length < total;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load more contacts';
|
||||
console.error('Failed to load more contacts:', e);
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
// All contacts are already loaded from IndexedDB
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a single contact by ID
|
||||
* Load a single contact by ID — reads from IndexedDB.
|
||||
*/
|
||||
async loadContact(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const contact = await contactsApi.get(id);
|
||||
selectedContact = contact;
|
||||
return contact;
|
||||
const local = await contactCollection.get(id);
|
||||
if (local) {
|
||||
selectedContact = toContact(local);
|
||||
return selectedContact;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load contact';
|
||||
console.error('Failed to load contact:', e);
|
||||
|
|
@ -170,90 +160,87 @@ export const contactsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Create a new contact
|
||||
* Requires authentication - demo mode shows auth gate
|
||||
* Create a new contact — writes to IndexedDB instantly.
|
||||
*/
|
||||
async createContact(data: Partial<Contact>) {
|
||||
// Demo mode: require authentication
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const contact = await contactsApi.create(data);
|
||||
// Add to local state
|
||||
contacts = [contact, ...contacts];
|
||||
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,
|
||||
};
|
||||
|
||||
const inserted = await contactCollection.insert(newLocal);
|
||||
const newContact = toContact(inserted);
|
||||
contacts = [newContact, ...contacts];
|
||||
total += 1;
|
||||
ContactsEvents.contactCreated();
|
||||
return contact;
|
||||
return newContact;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create contact';
|
||||
console.error('Failed to create contact:', e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a contact
|
||||
* Demo contacts require authentication
|
||||
* Update a contact — writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateContact(id: string, data: Partial<Contact>) {
|
||||
// Demo contact: require authentication
|
||||
if (isDemoContact(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const contact = await contactsApi.update(id, data);
|
||||
// Update in local state
|
||||
if (contact.isSelf) {
|
||||
selfContact = contact;
|
||||
} else {
|
||||
contacts = contacts.map((c) => (c.id === id ? contact : c));
|
||||
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;
|
||||
|
||||
const updated = await contactCollection.update(id, updateData);
|
||||
if (updated) {
|
||||
const updatedContact = toContact(updated);
|
||||
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = updatedContact;
|
||||
}
|
||||
ContactsEvents.contactUpdated();
|
||||
return updatedContact;
|
||||
}
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = contact;
|
||||
}
|
||||
ContactsEvents.contactUpdated();
|
||||
return contact;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update contact';
|
||||
console.error('Failed to update contact:', e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a contact
|
||||
* Demo contacts require authentication
|
||||
* Delete a contact — removes from IndexedDB instantly.
|
||||
*/
|
||||
async deleteContact(id: string) {
|
||||
// Demo contact: require authentication
|
||||
if (isDemoContact(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Prevent deleting self contact
|
||||
if (selfContact?.id === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await contactsApi.delete(id);
|
||||
// Remove from local state
|
||||
await contactCollection.delete(id);
|
||||
contacts = contacts.filter((c) => c.id !== id);
|
||||
total -= 1;
|
||||
if (selectedContact?.id === id) {
|
||||
|
|
@ -264,30 +251,29 @@ export const contactsStore = {
|
|||
error = e instanceof Error ? e.message : 'Failed to delete contact';
|
||||
console.error('Failed to delete contact:', e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
* Demo contacts require authentication
|
||||
* Toggle favorite status — writes to IndexedDB instantly.
|
||||
*/
|
||||
async toggleFavorite(id: string) {
|
||||
// Demo contact: require authentication
|
||||
if (isDemoContact(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
try {
|
||||
const contact = await contactsApi.toggleFavorite(id);
|
||||
// Update in local state
|
||||
contacts = contacts.map((c) => (c.id === id ? contact : c));
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = contact;
|
||||
const local = await contactCollection.get(id);
|
||||
if (!local) return;
|
||||
|
||||
const updated = await contactCollection.update(id, {
|
||||
isFavorite: !local.isFavorite,
|
||||
} as Partial<LocalContact>);
|
||||
if (updated) {
|
||||
const updatedContact = toContact(updated);
|
||||
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = updatedContact;
|
||||
}
|
||||
ContactsEvents.contactFavorited();
|
||||
return updatedContact;
|
||||
}
|
||||
ContactsEvents.contactFavorited();
|
||||
return contact;
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
throw e;
|
||||
|
|
@ -295,25 +281,26 @@ export const contactsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Toggle archive status
|
||||
* Demo contacts require authentication
|
||||
* Toggle archive status — writes to IndexedDB instantly.
|
||||
*/
|
||||
async toggleArchive(id: string) {
|
||||
// Demo contact: require authentication
|
||||
if (isDemoContact(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
try {
|
||||
const contact = await contactsApi.toggleArchive(id);
|
||||
// Remove from current view if archived/unarchived
|
||||
contacts = contacts.filter((c) => c.id !== id);
|
||||
total -= 1;
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = null;
|
||||
const local = await contactCollection.get(id);
|
||||
if (!local) return;
|
||||
|
||||
const updated = await contactCollection.update(id, {
|
||||
isArchived: !local.isArchived,
|
||||
} as Partial<LocalContact>);
|
||||
if (updated) {
|
||||
// Remove from current view (archived/unarchived toggle)
|
||||
contacts = contacts.filter((c) => c.id !== id);
|
||||
total -= 1;
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = null;
|
||||
}
|
||||
ContactsEvents.contactArchived();
|
||||
return toContact(updated);
|
||||
}
|
||||
ContactsEvents.contactArchived();
|
||||
return contact;
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle archive:', e);
|
||||
throw e;
|
||||
|
|
@ -321,7 +308,7 @@ export const contactsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Clear filters and reload
|
||||
* Clear filters and reload.
|
||||
*/
|
||||
async clearFilters() {
|
||||
filters = {};
|
||||
|
|
@ -329,30 +316,30 @@ export const contactsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Set search query
|
||||
* Set search query.
|
||||
*/
|
||||
setSearch(search: string) {
|
||||
filters = { ...filters, search };
|
||||
},
|
||||
|
||||
/**
|
||||
* Set tag filter
|
||||
* Set tag filter.
|
||||
*/
|
||||
setTagId(tagId: string | undefined) {
|
||||
filters = { ...filters, tagId };
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear selected contact
|
||||
* Clear selected contact.
|
||||
*/
|
||||
clearSelected() {
|
||||
selectedContact = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a contact is a demo contact (static sample data)
|
||||
* No longer relevant — all contacts are local and editable.
|
||||
*/
|
||||
isDemoContact(contactId: string) {
|
||||
return isDemoContact(contactId);
|
||||
isDemoContact(_contactId: string) {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -49,7 +49,18 @@
|
|||
import { tagsStore } from '$lib/stores/tags.svelte';
|
||||
import { contactsOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { contactsLocalStore } from '$lib/data/local-store';
|
||||
|
||||
// Guest welcome modal state
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
function initGuestWelcome() {
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('contacts')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Tags state for Quick-Create
|
||||
let availableTags = $state<{ id: string; name: string }[]>([]);
|
||||
|
|
@ -130,7 +141,8 @@
|
|||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown (fallback to 'Menü' when not logged in)
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
// User email for user dropdown — empty string for guests so PillNav shows login button
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
// TagStrip visibility (toggle via Tags button in PillNav)
|
||||
let isTagStripVisible = $state(true);
|
||||
|
|
@ -282,6 +294,14 @@
|
|||
});
|
||||
|
||||
async function handleAuthReady() {
|
||||
// Initialize local-first database (opens IndexedDB, seeds guest data)
|
||||
await contactsLocalStore.initialize();
|
||||
|
||||
// If authenticated, start syncing to server
|
||||
if (authStore.isAuthenticated) {
|
||||
contactsLocalStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
|
|
@ -290,18 +310,24 @@
|
|||
viewModeStore.initialize();
|
||||
contactsFilterStore.initialize();
|
||||
|
||||
// Load user settings and tags
|
||||
await userSettings.load();
|
||||
// Show guest welcome modal on first visit
|
||||
initGuestWelcome();
|
||||
|
||||
// Load tags (used by TagStrip and Quick-Create)
|
||||
await tagsStore.fetchTags();
|
||||
availableTags = tagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
|
||||
// Load contacts from IndexedDB (guest seed or synced data)
|
||||
await contactsStore.loadContacts();
|
||||
|
||||
// Load user settings and tags only when authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
await tagsStore.fetchTags();
|
||||
availableTags = tagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<SplitPaneContainer>
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
|
|
@ -332,7 +358,7 @@
|
|||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={true}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
|
|
@ -431,7 +457,19 @@
|
|||
<MiniOnboardingModal store={contactsOnboarding} appName="Kontakte" appEmoji="👥" />
|
||||
{/if}
|
||||
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
<!-- Guest Welcome Modal -->
|
||||
<GuestWelcomeModal
|
||||
appId="contacts"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
</AuthGate>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"vite": "^7.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
|
|
|
|||
51
apps/manadeck/apps/web/src/lib/data/guest-seed.ts
Normal file
51
apps/manadeck/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Guest seed data for the ManaDeck app.
|
||||
*
|
||||
* These records are loaded into IndexedDB when a new guest visits the app.
|
||||
* They serve as onboarding content that teaches the user how the app works.
|
||||
*/
|
||||
|
||||
import type { LocalDeck, LocalCard } from './local-store';
|
||||
|
||||
const ONBOARDING_DECK_ID = 'onboarding-deck';
|
||||
|
||||
export const guestDecks: LocalDeck[] = [
|
||||
{
|
||||
id: ONBOARDING_DECK_ID,
|
||||
name: 'Erste Schritte',
|
||||
description: 'Lerne ManaDeck kennen mit diesen Beispiel-Karteikarten.',
|
||||
color: '#6366f1',
|
||||
cardCount: 3,
|
||||
isPublic: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const guestCards: LocalCard[] = [
|
||||
{
|
||||
id: 'card-1',
|
||||
deckId: ONBOARDING_DECK_ID,
|
||||
front: 'Was ist ManaDeck?',
|
||||
back: 'ManaDeck ist eine Karteikarten-App zum effizienten Lernen mit Spaced Repetition.',
|
||||
difficulty: 1,
|
||||
reviewCount: 0,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'card-2',
|
||||
deckId: ONBOARDING_DECK_ID,
|
||||
front: 'Wie funktioniert Spaced Repetition?',
|
||||
back: 'Karten, die du gut kennst, werden seltener gezeigt. Schwierige Karten erscheinen häufiger, bis du sie beherrschst.',
|
||||
difficulty: 2,
|
||||
reviewCount: 0,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'card-3',
|
||||
deckId: ONBOARDING_DECK_ID,
|
||||
front: 'Wie erstelle ich ein neues Deck?',
|
||||
back: 'Klicke auf den + Button auf der Decks-Seite, um ein neues Deck mit eigenen Karteikarten zu erstellen.',
|
||||
difficulty: 1,
|
||||
reviewCount: 0,
|
||||
order: 2,
|
||||
},
|
||||
];
|
||||
57
apps/manadeck/apps/web/src/lib/data/local-store.ts
Normal file
57
apps/manadeck/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* ManaDeck — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* This is the single source of truth for all ManaDeck data.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import { guestDecks, guestCards } from './guest-seed';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface LocalDeck extends BaseRecord {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color: string;
|
||||
cardCount: number;
|
||||
lastStudied?: string | null;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface LocalCard extends BaseRecord {
|
||||
deckId: string;
|
||||
front: string;
|
||||
back: string;
|
||||
difficulty: number; // 1-5
|
||||
nextReview?: string | null;
|
||||
reviewCount: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const manadeckStore = createLocalStore({
|
||||
appId: 'manadeck',
|
||||
collections: [
|
||||
{
|
||||
name: 'decks',
|
||||
indexes: ['isPublic'],
|
||||
guestSeed: guestDecks,
|
||||
},
|
||||
{
|
||||
name: 'cards',
|
||||
indexes: ['deckId', 'difficulty', 'nextReview', 'order', '[deckId+order]'],
|
||||
guestSeed: guestCards,
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const deckCollection = manadeckStore.collection<LocalDeck>('decks');
|
||||
export const cardCollection = manadeckStore.collection<LocalCard>('cards');
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
/**
|
||||
* Card Store — Local-First with Dexie.js
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import type { Card, CreateCardInput, UpdateCardInput } from '$lib/types/card';
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import { authService } from '$lib/auth';
|
||||
import { cardCollection, deckCollection, type LocalCard } from '$lib/data/local-store';
|
||||
import { ManaDeckEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
// Svelte 5 runes-based card store
|
||||
|
|
@ -9,49 +16,22 @@ let currentCard = $state<Card | null>(null);
|
|||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/**
|
||||
* Helper to make authenticated API requests
|
||||
*/
|
||||
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backend camelCase to frontend snake_case
|
||||
*/
|
||||
function mapCardFromApi(apiCard: any): Card {
|
||||
/** Convert a LocalCard (IndexedDB record) to the shared Card type. */
|
||||
function toCard(local: LocalCard): Card {
|
||||
return {
|
||||
id: apiCard.id,
|
||||
deck_id: apiCard.deckId,
|
||||
position: apiCard.position,
|
||||
title: apiCard.title,
|
||||
content: apiCard.content,
|
||||
card_type: apiCard.cardType,
|
||||
ai_model: apiCard.aiModel,
|
||||
ai_prompt: apiCard.aiPrompt,
|
||||
version: apiCard.version || 1,
|
||||
is_favorite: apiCard.isFavorite || false,
|
||||
created_at: apiCard.createdAt,
|
||||
updated_at: apiCard.updatedAt,
|
||||
id: local.id,
|
||||
deck_id: local.deckId,
|
||||
position: local.order,
|
||||
title: local.front,
|
||||
content: {
|
||||
front: local.front,
|
||||
back: local.back,
|
||||
},
|
||||
card_type: 'flashcard',
|
||||
version: 1,
|
||||
is_favorite: false,
|
||||
created_at: local.createdAt ?? new Date().toISOString(),
|
||||
updated_at: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -70,17 +50,18 @@ export const cardStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch all cards for a deck
|
||||
* Fetch all cards for a deck — reads from IndexedDB.
|
||||
*/
|
||||
async fetchCards(deckId: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiRequest<{ cards: any[]; count: number }>(
|
||||
`/v1/api/decks/${deckId}/cards`
|
||||
const localCards = await cardCollection.getAll(
|
||||
{ deckId },
|
||||
{ sortBy: 'order', sortDirection: 'asc' }
|
||||
);
|
||||
cards = (response.cards || []).map(mapCardFromApi);
|
||||
cards = localCards.map(toCard);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to fetch cards';
|
||||
console.error('Fetch cards error:', err);
|
||||
|
|
@ -90,15 +71,15 @@ export const cardStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch single card by ID
|
||||
* Fetch single card by ID — reads from IndexedDB.
|
||||
*/
|
||||
async fetchCard(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiRequest<{ card: any }>(`/v1/api/cards/${id}`);
|
||||
currentCard = response.card ? mapCardFromApi(response.card) : null;
|
||||
const localCard = await cardCollection.get(id);
|
||||
currentCard = localCard ? toCard(localCard) : null;
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to fetch card';
|
||||
console.error('Fetch card error:', err);
|
||||
|
|
@ -108,31 +89,38 @@ export const cardStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Create new card
|
||||
* Create new card — writes to IndexedDB instantly.
|
||||
*/
|
||||
async createCard(input: CreateCardInput): Promise<Card | null> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; card: any }>('/v1/api/cards', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
deckId: input.deck_id,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
cardType: input.card_type,
|
||||
position: input.position,
|
||||
}),
|
||||
});
|
||||
const content = input.content as { front?: string; back?: string; text?: string };
|
||||
const newLocal: LocalCard = {
|
||||
id: crypto.randomUUID(),
|
||||
deckId: input.deck_id,
|
||||
front: content.front || content.text || input.title || '',
|
||||
back: content.back || '',
|
||||
difficulty: 1,
|
||||
reviewCount: 0,
|
||||
order: input.position ?? cards.length,
|
||||
};
|
||||
|
||||
if (response.card) {
|
||||
const card = mapCardFromApi(response.card);
|
||||
cards = [...cards, card];
|
||||
ManaDeckEvents.cardCreated();
|
||||
return card;
|
||||
const inserted = await cardCollection.insert(newLocal);
|
||||
const card = toCard(inserted);
|
||||
cards = [...cards, card];
|
||||
|
||||
// Update deck card count
|
||||
const deck = await deckCollection.get(input.deck_id);
|
||||
if (deck) {
|
||||
await deckCollection.update(input.deck_id, {
|
||||
cardCount: (deck.cardCount || 0) + 1,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
|
||||
ManaDeckEvents.cardCreated();
|
||||
return card;
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to create card';
|
||||
console.error('Create card error:', err);
|
||||
|
|
@ -143,30 +131,27 @@ export const cardStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Update card
|
||||
* Update card — writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateCard(id: string, updates: UpdateCardInput) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; card: any }>(`/v1/api/cards/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title: updates.title,
|
||||
content: updates.content,
|
||||
cardType: updates.card_type,
|
||||
position: updates.position,
|
||||
isFavorite: updates.is_favorite,
|
||||
}),
|
||||
});
|
||||
const localUpdates: Partial<LocalCard> = {};
|
||||
if (updates.content) {
|
||||
const content = updates.content as { front?: string; back?: string; text?: string };
|
||||
if (content.front !== undefined) localUpdates.front = content.front;
|
||||
if (content.back !== undefined) localUpdates.back = content.back;
|
||||
}
|
||||
if (updates.title !== undefined) localUpdates.front = updates.title;
|
||||
if (updates.position !== undefined) localUpdates.order = updates.position;
|
||||
|
||||
if (response.card) {
|
||||
const updatedCard = mapCardFromApi(response.card);
|
||||
// Update in list
|
||||
const updated = await cardCollection.update(id, localUpdates);
|
||||
if (updated) {
|
||||
const updatedCard = toCard(updated);
|
||||
cards = cards.map((c) => (c.id === id ? updatedCard : c));
|
||||
|
||||
// Update current if it's the same
|
||||
if (currentCard?.id === id) {
|
||||
currentCard = updatedCard;
|
||||
}
|
||||
|
|
@ -180,22 +165,30 @@ export const cardStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Delete card
|
||||
* Delete card — writes to IndexedDB instantly.
|
||||
*/
|
||||
async deleteCard(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await apiRequest<{ success: boolean }>(`/v1/api/cards/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
// Remove from list
|
||||
// Find the card to get its deckId before deleting
|
||||
const card = cards.find((c) => c.id === id);
|
||||
await cardCollection.delete(id);
|
||||
cards = cards.filter((c) => c.id !== id);
|
||||
|
||||
// Update deck card count
|
||||
if (card) {
|
||||
const deck = await deckCollection.get(card.deck_id);
|
||||
if (deck) {
|
||||
await deckCollection.update(card.deck_id, {
|
||||
cardCount: Math.max(0, (deck.cardCount || 0) - 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ManaDeckEvents.cardDeleted();
|
||||
|
||||
// Clear current if it's the same
|
||||
if (currentCard?.id === id) {
|
||||
currentCard = null;
|
||||
}
|
||||
|
|
@ -208,18 +201,13 @@ export const cardStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Reorder cards
|
||||
* Reorder cards — writes to IndexedDB instantly.
|
||||
*/
|
||||
async reorderCards(deckId: string, cardIds: string[]) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await apiRequest<{ success: boolean }>('/v1/api/cards/reorder', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ deckId, cardIds }),
|
||||
});
|
||||
|
||||
// Update local positions
|
||||
cards = cardIds
|
||||
.map((id, index) => {
|
||||
|
|
@ -227,6 +215,11 @@ export const cardStore = {
|
|||
return card ? { ...card, position: index } : card!;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Persist each order change to IndexedDB
|
||||
for (let i = 0; i < cardIds.length; i++) {
|
||||
await cardCollection.update(cardIds[i], { order: i } as Partial<LocalCard>);
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to reorder cards';
|
||||
console.error('Reorder cards error:', err);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
/**
|
||||
* Deck Store — Local-First with Dexie.js
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import { authService } from '$lib/auth';
|
||||
import { deckCollection, cardCollection, type LocalDeck } from '$lib/data/local-store';
|
||||
import { ManaDeckEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
// Svelte 5 runes-based deck store
|
||||
|
|
@ -9,30 +16,21 @@ let currentDeck = $state<Deck | null>(null);
|
|||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/**
|
||||
* Helper to make authenticated API requests
|
||||
*/
|
||||
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
/** Convert a LocalDeck (IndexedDB record) to the shared Deck type. */
|
||||
function toDeck(local: LocalDeck): Deck {
|
||||
return {
|
||||
id: local.id,
|
||||
user_id: 'guest',
|
||||
title: local.name,
|
||||
description: local.description ?? undefined,
|
||||
is_public: local.isPublic,
|
||||
settings: {},
|
||||
tags: [],
|
||||
metadata: {},
|
||||
created_at: local.createdAt ?? new Date().toISOString(),
|
||||
updated_at: local.updatedAt ?? new Date().toISOString(),
|
||||
card_count: local.cardCount,
|
||||
};
|
||||
}
|
||||
|
||||
export const deckStore = {
|
||||
|
|
@ -50,15 +48,15 @@ export const deckStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch all decks for current user
|
||||
* Fetch all decks for current user — reads from IndexedDB.
|
||||
*/
|
||||
async fetchDecks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiRequest<{ decks: Deck[]; count: number }>('/v1/api/decks');
|
||||
decks = response.decks || [];
|
||||
const localDecks = await deckCollection.getAll();
|
||||
decks = localDecks.map(toDeck);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to fetch decks';
|
||||
console.error('Fetch decks error:', err);
|
||||
|
|
@ -68,16 +66,18 @@ export const deckStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch single deck by ID
|
||||
* Fetch single deck by ID — reads from IndexedDB.
|
||||
*/
|
||||
async fetchDeck(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiRequest<{ deck: Deck }>(`/v1/api/decks/${id}`);
|
||||
currentDeck = response.deck || null;
|
||||
if (!currentDeck) {
|
||||
const localDeck = await deckCollection.get(id);
|
||||
if (localDeck) {
|
||||
currentDeck = toDeck(localDeck);
|
||||
} else {
|
||||
currentDeck = null;
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
|
@ -89,31 +89,27 @@ export const deckStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Create new deck
|
||||
* Create new deck — writes to IndexedDB instantly.
|
||||
*/
|
||||
async createDeck(input: CreateDeckInput): Promise<Deck | null> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; deck: Deck }>('/v1/api/decks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
description: input.description || '',
|
||||
isPublic: input.is_public ?? false,
|
||||
tags: input.tags || [],
|
||||
settings: input.settings || {},
|
||||
}),
|
||||
});
|
||||
const newLocal: LocalDeck = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.title,
|
||||
description: input.description || null,
|
||||
color: '#6366f1',
|
||||
cardCount: 0,
|
||||
isPublic: input.is_public ?? false,
|
||||
};
|
||||
|
||||
if (response.deck) {
|
||||
const deck = { ...response.deck, card_count: 0 };
|
||||
decks = [deck, ...decks];
|
||||
ManaDeckEvents.deckCreated();
|
||||
return deck;
|
||||
}
|
||||
return null;
|
||||
const inserted = await deckCollection.insert(newLocal);
|
||||
const deck = toDeck(inserted);
|
||||
decks = [deck, ...decks];
|
||||
ManaDeckEvents.deckCreated();
|
||||
return deck;
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to create deck';
|
||||
console.error('Create deck error:', err);
|
||||
|
|
@ -124,25 +120,25 @@ export const deckStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Update deck
|
||||
* Update deck — writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateDeck(id: string, updates: UpdateDeckInput) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; deck: Deck }>(`/v1/api/decks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
const localUpdates: Partial<LocalDeck> = {};
|
||||
if (updates.title !== undefined) localUpdates.name = updates.title;
|
||||
if (updates.description !== undefined) localUpdates.description = updates.description;
|
||||
if (updates.is_public !== undefined) localUpdates.isPublic = updates.is_public;
|
||||
|
||||
if (response.deck) {
|
||||
// Update in list
|
||||
decks = decks.map((d) => (d.id === id ? { ...d, ...response.deck } : d));
|
||||
const updated = await deckCollection.update(id, localUpdates);
|
||||
if (updated) {
|
||||
const updatedDeck = toDeck(updated);
|
||||
decks = decks.map((d) => (d.id === id ? updatedDeck : d));
|
||||
|
||||
// Update current if it's the same
|
||||
if (currentDeck?.id === id) {
|
||||
currentDeck = { ...currentDeck, ...response.deck };
|
||||
currentDeck = updatedDeck;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
|
@ -154,22 +150,23 @@ export const deckStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Delete deck
|
||||
* Delete deck — writes to IndexedDB instantly.
|
||||
*/
|
||||
async deleteDeck(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await apiRequest<{ success: boolean }>(`/v1/api/decks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
// Delete all cards belonging to this deck
|
||||
const cards = await cardCollection.getAll({ deckId: id });
|
||||
for (const card of cards) {
|
||||
await cardCollection.delete(card.id);
|
||||
}
|
||||
|
||||
// Remove from list
|
||||
await deckCollection.delete(id);
|
||||
decks = decks.filter((d) => d.id !== id);
|
||||
ManaDeckEvents.deckDeleted();
|
||||
|
||||
// Clear current if it's the same
|
||||
if (currentDeck?.id === id) {
|
||||
currentDeck = null;
|
||||
}
|
||||
|
|
@ -187,4 +184,14 @@ export const deckStore = {
|
|||
clearError() {
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state
|
||||
*/
|
||||
clear() {
|
||||
decks = [];
|
||||
currentDeck = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
|
|
@ -23,6 +22,9 @@
|
|||
import { deckStore } from '$lib/stores/deckStore.svelte';
|
||||
import { manadeckOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { manadeckStore } from '$lib/data/local-store';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('manadeck');
|
||||
|
|
@ -100,8 +102,11 @@
|
|||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email);
|
||||
// Guest welcome modal state
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
// User email for user dropdown — empty string for guests so PillNav shows login button
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
|
|
@ -171,17 +176,28 @@
|
|||
goto(`/decks/${item.id}`);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
async function handleAuthReady() {
|
||||
// Initialize local-first database (opens IndexedDB, seeds guest data)
|
||||
await manadeckStore.initialize();
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
// If authenticated, start syncing to server
|
||||
if (authStore.isAuthenticated) {
|
||||
manadeckStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
|
||||
// Load user settings and tags
|
||||
await userSettings.load();
|
||||
await tagStore.fetchTags();
|
||||
// Load decks from IndexedDB (guest seed or synced data)
|
||||
await deckStore.fetchDecks();
|
||||
|
||||
// Show guest welcome modal on first visit
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('manadeck')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
// Load user settings and tags (require auth)
|
||||
await userSettings.load();
|
||||
await tagStore.fetchTags();
|
||||
}
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
|
|
@ -195,21 +211,12 @@
|
|||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if authStore.loading}
|
||||
<div class="min-h-screen flex items-center justify-center bg-background">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-block animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if authStore.isAuthenticated}
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- Pill Navigation -->
|
||||
<PillNavigation
|
||||
|
|
@ -231,7 +238,8 @@
|
|||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={true}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
loginHref="/login"
|
||||
primaryColor="#6366f1"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
|
|
@ -284,4 +292,18 @@
|
|||
{#if manadeckOnboarding.shouldShow}
|
||||
<MiniOnboardingModal store={manadeckOnboarding} appName="ManaDeck" appEmoji="🃏" />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Guest Welcome Modal -->
|
||||
<GuestWelcomeModal
|
||||
appId="manadeck"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
</AuthGate>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@
|
|||
"dev:chat:backend": "pnpm --filter @chat/backend start:dev",
|
||||
"dev:chat:app": "turbo run dev --filter=@chat/web --filter=@chat/backend",
|
||||
"dev:auth": "pnpm --filter mana-core-auth start:dev",
|
||||
"dev:sync": "cd services/mana-sync && JWKS_URL=http://localhost:3001/api/auth/jwks DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mana_sync ./server",
|
||||
"dev:sync:build": "cd services/mana-sync && go build -o server ./cmd/server",
|
||||
"dev:chat:full": "./scripts/setup-databases.sh chat && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:chat:backend\" \"pnpm dev:chat:web\"",
|
||||
"zitare:dev": "turbo run dev --filter=zitare...",
|
||||
"dev:zitare:mobile": "pnpm --filter @zitare/mobile dev",
|
||||
|
|
@ -106,8 +108,10 @@
|
|||
"dev:todo:web": "pnpm --filter @todo/web dev",
|
||||
"dev:todo:landing": "pnpm --filter @todo/landing dev",
|
||||
"dev:todo:backend": "pnpm --filter @todo/backend dev",
|
||||
"dev:todo:server": "cd apps/todo/apps/server && bun run --watch src/index.ts",
|
||||
"dev:todo:app": "turbo run dev --filter=@todo/web --filter=@todo/backend",
|
||||
"dev:todo:full": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\"",
|
||||
"dev:todo:full": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh auth && concurrently -n auth,sync,server,backend,web -c blue,magenta,yellow,green,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:todo:server\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\"",
|
||||
"dev:todo:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:todo:server\" \"pnpm dev:todo:web\"",
|
||||
"todo:db:push": "pnpm --filter @todo/backend db:push",
|
||||
"todo:db:studio": "pnpm --filter @todo/backend db:studio",
|
||||
"todo:db:seed": "pnpm --filter @todo/backend db:seed",
|
||||
|
|
@ -119,6 +123,8 @@
|
|||
"photos:db:push": "pnpm --filter @photos/backend db:push",
|
||||
"photos:db:studio": "pnpm --filter @photos/backend db:studio",
|
||||
"dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
|
||||
"inventar:dev": "turbo run dev --filter=inventar...",
|
||||
"dev:inventar:web": "pnpm --filter @inventar/web dev",
|
||||
"moodlit:dev": "turbo run dev --filter=moodlit...",
|
||||
"dev:moodlit:mobile": "pnpm --filter @moodlit/mobile dev",
|
||||
"dev:moodlit:web": "pnpm --filter @moodlit/web dev",
|
||||
|
|
|
|||
223
pnpm-lock.yaml
generated
223
pnpm-lock.yaml
generated
|
|
@ -134,7 +134,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
expo-server-sdk:
|
||||
specifier: ^3.10.0
|
||||
version: 3.15.0(encoding@0.1.13)
|
||||
|
|
@ -281,6 +281,9 @@ importers:
|
|||
'@calendar/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@manacore/local-store':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/local-store
|
||||
'@manacore/shared-api-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-api-client
|
||||
|
|
@ -505,7 +508,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
openai:
|
||||
specifier: ^4.77.0
|
||||
version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)
|
||||
|
|
@ -892,7 +895,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -1128,7 +1131,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -1242,6 +1245,9 @@ importers:
|
|||
'@clock/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@manacore/local-store':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/local-store
|
||||
'@manacore/shared-api-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-api-client
|
||||
|
|
@ -1443,7 +1449,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
googleapis:
|
||||
specifier: ^144.0.0
|
||||
version: 144.0.0(encoding@0.1.13)
|
||||
|
|
@ -1532,6 +1538,9 @@ importers:
|
|||
|
||||
apps/contacts/apps/web:
|
||||
dependencies:
|
||||
'@manacore/local-store':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/local-store
|
||||
'@manacore/shared-api-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-api-client
|
||||
|
|
@ -1714,7 +1723,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -2822,6 +2831,9 @@ importers:
|
|||
|
||||
apps/manadeck/apps/web:
|
||||
dependencies:
|
||||
'@manacore/local-store':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/local-store
|
||||
'@manacore/shared-app-onboarding':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-app-onboarding
|
||||
|
|
@ -3260,7 +3272,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
music-metadata:
|
||||
specifier: ^11.12.3
|
||||
version: 11.12.3
|
||||
|
|
@ -3556,7 +3568,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -3859,7 +3871,7 @@ importers:
|
|||
version: 16.6.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -4114,7 +4126,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
multer:
|
||||
specifier: ^1.4.5-lts.1
|
||||
version: 1.4.5-lts.2
|
||||
|
|
@ -4690,7 +4702,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
multer:
|
||||
specifier: ^1.4.5-lts.1
|
||||
version: 1.4.5-lts.2
|
||||
|
|
@ -5001,7 +5013,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
nanoid:
|
||||
specifier: ^5.0.9
|
||||
version: 5.1.6
|
||||
|
|
@ -5237,7 +5249,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -5464,7 +5476,7 @@ importers:
|
|||
version: 16.6.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -5669,7 +5681,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
nestjs-pino:
|
||||
specifier: ^4.6.1
|
||||
version: 4.6.1(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2)
|
||||
|
|
@ -5950,7 +5962,7 @@ importers:
|
|||
version: 16.6.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -6047,6 +6059,28 @@ importers:
|
|||
specifier: ^3.4.0
|
||||
version: 3.4.18(tsx@4.21.0)(yaml@2.8.1)
|
||||
|
||||
apps/todo/apps/server:
|
||||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.1
|
||||
version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.2.0)(kysely@0.28.8)(postgres@3.4.7)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.9
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
rrule:
|
||||
specifier: ^2.8.1
|
||||
version: 2.8.1
|
||||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: ^1.2.0
|
||||
version: 1.3.11
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
apps/todo/apps/web:
|
||||
dependencies:
|
||||
'@manacore/local-store':
|
||||
|
|
@ -6259,7 +6293,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -6471,7 +6505,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -6794,7 +6828,7 @@ importers:
|
|||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -6859,7 +6893,7 @@ importers:
|
|||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
|
|
@ -7157,6 +7191,15 @@ importers:
|
|||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/shared-gpu:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.25
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/shared-help-content:
|
||||
dependencies:
|
||||
'@manacore/shared-help-types':
|
||||
|
|
@ -7751,7 +7794,7 @@ importers:
|
|||
version: 0.14.3
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.4
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
ioredis:
|
||||
specifier: ^5.4.2
|
||||
version: 5.8.2
|
||||
|
|
@ -7881,7 +7924,7 @@ importers:
|
|||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
duckdb-async:
|
||||
specifier: ^1.1.1
|
||||
version: 1.4.2(encoding@0.1.13)
|
||||
|
|
@ -8047,7 +8090,7 @@ importers:
|
|||
version: 0.14.3
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.4
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
ioredis:
|
||||
specifier: ^5.4.2
|
||||
version: 5.8.2
|
||||
|
|
@ -8189,7 +8232,7 @@ importers:
|
|||
version: 5.67.2
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
exifr:
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
|
|
@ -8289,7 +8332,7 @@ importers:
|
|||
version: 0.14.3
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.4
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
expo-server-sdk:
|
||||
specifier: ^3.10.0
|
||||
version: 3.15.0(encoding@0.1.13)
|
||||
|
|
@ -8968,7 +9011,7 @@ importers:
|
|||
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
|
||||
matrix-bot-sdk:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -15810,6 +15853,9 @@ packages:
|
|||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/bun@1.3.11':
|
||||
resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
|
|
@ -17692,6 +17738,9 @@ packages:
|
|||
bullmq@5.67.2:
|
||||
resolution: {integrity: sha512-3KYqNqQptKcgksACO1li4YW9/jxEh6XWa1lUg4OFrHa80Pf0C7H9zeb6ssbQQDfQab/K3QCXopbZ40vrvcyrLw==}
|
||||
|
||||
bun-types@1.3.11:
|
||||
resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==}
|
||||
|
||||
bundle-require@5.1.0:
|
||||
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
@ -18959,6 +19008,98 @@ packages:
|
|||
sqlite3:
|
||||
optional: true
|
||||
|
||||
drizzle-orm@0.45.1:
|
||||
resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==}
|
||||
peerDependencies:
|
||||
'@aws-sdk/client-rds-data': '>=3'
|
||||
'@cloudflare/workers-types': '>=4'
|
||||
'@electric-sql/pglite': '>=0.2.0'
|
||||
'@libsql/client': '>=0.10.0'
|
||||
'@libsql/client-wasm': '>=0.10.0'
|
||||
'@neondatabase/serverless': '>=0.10.0'
|
||||
'@op-engineering/op-sqlite': '>=2'
|
||||
'@opentelemetry/api': ^1.4.1
|
||||
'@planetscale/database': '>=1.13'
|
||||
'@prisma/client': '*'
|
||||
'@tidbcloud/serverless': '*'
|
||||
'@types/better-sqlite3': '*'
|
||||
'@types/pg': '*'
|
||||
'@types/sql.js': '*'
|
||||
'@upstash/redis': '>=1.34.7'
|
||||
'@vercel/postgres': '>=0.8.0'
|
||||
'@xata.io/client': '*'
|
||||
better-sqlite3: '>=7'
|
||||
bun-types: '*'
|
||||
expo-sqlite: '>=14.0.0'
|
||||
gel: '>=2'
|
||||
knex: '*'
|
||||
kysely: '*'
|
||||
mysql2: '>=2'
|
||||
pg: '>=8'
|
||||
postgres: '>=3'
|
||||
prisma: '*'
|
||||
sql.js: '>=1'
|
||||
sqlite3: '>=5'
|
||||
peerDependenciesMeta:
|
||||
'@aws-sdk/client-rds-data':
|
||||
optional: true
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
'@electric-sql/pglite':
|
||||
optional: true
|
||||
'@libsql/client':
|
||||
optional: true
|
||||
'@libsql/client-wasm':
|
||||
optional: true
|
||||
'@neondatabase/serverless':
|
||||
optional: true
|
||||
'@op-engineering/op-sqlite':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@planetscale/database':
|
||||
optional: true
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
'@tidbcloud/serverless':
|
||||
optional: true
|
||||
'@types/better-sqlite3':
|
||||
optional: true
|
||||
'@types/pg':
|
||||
optional: true
|
||||
'@types/sql.js':
|
||||
optional: true
|
||||
'@upstash/redis':
|
||||
optional: true
|
||||
'@vercel/postgres':
|
||||
optional: true
|
||||
'@xata.io/client':
|
||||
optional: true
|
||||
better-sqlite3:
|
||||
optional: true
|
||||
bun-types:
|
||||
optional: true
|
||||
expo-sqlite:
|
||||
optional: true
|
||||
gel:
|
||||
optional: true
|
||||
knex:
|
||||
optional: true
|
||||
kysely:
|
||||
optional: true
|
||||
mysql2:
|
||||
optional: true
|
||||
pg:
|
||||
optional: true
|
||||
postgres:
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
sql.js:
|
||||
optional: true
|
||||
sqlite3:
|
||||
optional: true
|
||||
|
||||
dset@3.1.4:
|
||||
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -21217,6 +21358,10 @@ packages:
|
|||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
hono@4.12.9:
|
||||
resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hosted-git-info@7.0.2:
|
||||
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
|
||||
engines: {node: ^16.14.0 || >=18.0.0}
|
||||
|
|
@ -40211,6 +40356,10 @@ snapshots:
|
|||
'@types/connect': 3.4.38
|
||||
'@types/node': 22.19.1
|
||||
|
||||
'@types/bun@1.3.11':
|
||||
dependencies:
|
||||
bun-types: 1.3.11
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
|
|
@ -43539,6 +43688,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
bun-types@1.3.11:
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
|
||||
bundle-require@5.1.0(esbuild@0.27.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.0
|
||||
|
|
@ -44673,26 +44826,38 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
drizzle-orm@0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4):
|
||||
drizzle-orm@0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/pg': 8.6.1
|
||||
'@types/react': 19.2.14
|
||||
bun-types: 1.3.11
|
||||
expo-sqlite: 55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
|
||||
kysely: 0.28.8
|
||||
postgres: 3.4.7
|
||||
react: 19.2.4
|
||||
|
||||
drizzle-orm@0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4):
|
||||
drizzle-orm@0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/pg': 8.6.1
|
||||
'@types/react': 19.2.14
|
||||
bun-types: 1.3.11
|
||||
expo-sqlite: 55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
|
||||
kysely: 0.28.8
|
||||
postgres: 3.4.7
|
||||
react: 19.2.4
|
||||
|
||||
drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.2.0)(kysely@0.28.8)(postgres@3.4.7):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/pg': 8.6.1
|
||||
bun-types: 1.3.11
|
||||
expo-sqlite: 55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.8
|
||||
postgres: 3.4.7
|
||||
|
||||
dset@3.1.4: {}
|
||||
|
||||
duckdb-async@1.4.2(encoding@0.1.13):
|
||||
|
|
@ -49554,6 +49719,8 @@ snapshots:
|
|||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
hono@4.12.9: {}
|
||||
|
||||
hosted-git-info@7.0.2:
|
||||
dependencies:
|
||||
lru-cache: 10.4.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue