mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 12:46:42 +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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue