diff --git a/apps/calendar/apps/web/src/lib/components/AuthGateModal.svelte b/apps/calendar/apps/web/src/lib/components/AuthGateModal.svelte new file mode 100644 index 000000000..6b08118db --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/AuthGateModal.svelte @@ -0,0 +1,167 @@ + + + + +{#if visible} + +
+ +
+{/if} diff --git a/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts b/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts index bf9489a31..5d9958fb1 100644 --- a/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts @@ -1,11 +1,26 @@ /** * Calendars Store - Manages user calendars using Svelte 5 runes + * Supports both authenticated (cloud) and guest (session) modes */ import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared'; import * as api from '$lib/api/calendars'; import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays'; import { settingsStore } from './settings.svelte'; +import { authStore } from './auth.svelte'; + +// 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([]); @@ -20,6 +35,7 @@ const birthdayCalendar: Calendar = { color: BIRTHDAY_CALENDAR.color, isDefault: false, isVisible: true, // Visibility controlled by settingsStore.showBirthdays + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -75,11 +91,20 @@ export const calendarsStore = { /** * Fetch all calendars + * In guest mode, returns a default local calendar */ 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) { @@ -195,4 +220,18 @@ export const calendarsStore = { isBirthdayCalendar(id: string) { return id === BIRTHDAY_CALENDAR.id; }, + + /** + * Check if a calendar ID is the guest calendar + */ + isGuestCalendar(id: string) { + return id === GUEST_CALENDAR.id; + }, + + /** + * Get the guest calendar ID + */ + get guestCalendarId() { + return GUEST_CALENDAR.id; + }, }; diff --git a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts index 2619ea3db..6db5ee9ec 100644 --- a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts @@ -1,5 +1,6 @@ /** * Events Store - Manages calendar events using Svelte 5 runes + * Supports both authenticated (cloud) and guest (session) modes */ import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared'; @@ -7,6 +8,8 @@ import * as api from '$lib/api/events'; import { format, isWithinInterval, isSameDay } from 'date-fns'; import { toDate } from '$lib/utils/eventDateHelpers'; import { toastStore } from './toast.svelte'; +import { sessionEventsStore } from './session-events.svelte'; +import { authStore } from './auth.svelte'; // State let events = $state([]); @@ -34,11 +37,31 @@ export const eventsStore = { /** * Fetch events for a date range + * In guest mode, only shows session events */ async fetchEvents(startDate: Date, endDate: Date, calendarIds?: string[]) { loading = true; error = null; + // Guest mode: load session events only + if (!authStore.isAuthenticated) { + // Initialize session events store if needed + sessionEventsStore.initialize(); + + // Filter session events by date range + const sessionEvents = sessionEventsStore.events.filter((event) => { + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); + return eventStart <= endDate && eventEnd >= startDate; + }); + + events = sessionEvents; + loadedRange = { start: startDate, end: endDate }; + loading = false; + return { data: sessionEvents, error: null }; + } + + // Authenticated: fetch from API 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"), @@ -114,8 +137,22 @@ export const eventsStore = { /** * Create a new event + * If not authenticated, creates a session event (local only) */ async createEvent(data: CreateEventInput) { + // Guest mode: create session event + if (!authStore.isAuthenticated) { + const sessionEvent = sessionEventsStore.createEvent({ + ...data, + calendarId: data.calendarId || 'session-calendar', + }); + // Add to local events array for immediate display + events = [...events, sessionEvent]; + toastStore.success('Termin erstellt (lokal gespeichert)'); + return { data: sessionEvent, error: null }; + } + + // Authenticated: create via API const result = await api.createEvent(data); if (result.data) { @@ -127,8 +164,20 @@ export const eventsStore = { /** * Update an event + * Handles both session events (local) and cloud events */ async updateEvent(id: string, data: UpdateEventInput) { + // Session event: update locally + if (sessionEventsStore.isSessionEvent(id)) { + const updated = sessionEventsStore.updateEvent(id, data); + if (updated) { + events = events.map((e) => (e.id === id ? updated : e)); + return { data: updated, error: null }; + } + return { data: null, error: new Error('Event not found') }; + } + + // Cloud event: update via API const result = await api.updateEvent(id, data); if (result.error) { @@ -142,8 +191,18 @@ export const eventsStore = { /** * Delete an event (optimistic update) + * Handles both session events (local) and cloud events */ async deleteEvent(id: string) { + // Session event: delete locally + if (sessionEventsStore.isSessionEvent(id)) { + sessionEventsStore.deleteEvent(id); + events = events.filter((e) => e.id !== id); + toastStore.success('Termin gelöscht'); + return { data: null, error: null }; + } + + // Cloud event: delete via API // Optimistic: remove event immediately const eventToDelete = events.find((e) => e.id === id); events = events.filter((e) => e.id !== id); @@ -235,4 +294,70 @@ export const eventsStore = { isDraftEvent(eventId: string) { return eventId === '__draft__'; }, + + /** + * Check if an event is a session event (local only) + */ + isSessionEvent(eventId: string) { + return sessionEventsStore.isSessionEvent(eventId); + }, + + /** + * Migrate session events to cloud after login + * Call this after successful authentication + */ + async migrateSessionEvents(defaultCalendarId?: string) { + const sessionEvents = sessionEventsStore.getAllEvents(); + if (sessionEvents.length === 0) return { migrated: 0, failed: 0 }; + + let migrated = 0; + let failed = 0; + + for (const sessionEvent of sessionEvents) { + try { + const result = await api.createEvent({ + calendarId: defaultCalendarId || sessionEvent.calendarId, + title: sessionEvent.title, + description: sessionEvent.description || undefined, + location: sessionEvent.location || undefined, + startTime: sessionEvent.startTime, + endTime: sessionEvent.endTime, + isAllDay: sessionEvent.isAllDay, + color: sessionEvent.color || undefined, + }); + + if (result.data) { + migrated++; + } else { + failed++; + } + } catch { + failed++; + } + } + + // Clear session events after migration + if (migrated > 0) { + sessionEventsStore.clear(); + toastStore.success( + `${migrated} ${migrated === 1 ? 'Termin' : 'Termine'} in die Cloud übernommen` + ); + } + + return { migrated, failed }; + }, + + /** + * Get count of pending session events + */ + get sessionEventCount() { + return sessionEventsStore.count; + }, + + /** + * Check if there are pending session events to migrate + */ + get hasSessionEvents() { + return sessionEventsStore.hasEvents; + }, }; diff --git a/apps/calendar/apps/web/src/lib/stores/session-events.svelte.ts b/apps/calendar/apps/web/src/lib/stores/session-events.svelte.ts new file mode 100644 index 000000000..a86b89795 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/session-events.svelte.ts @@ -0,0 +1,153 @@ +/** + * Session Events Store - Temporary local events for guest users + * Events are stored in sessionStorage and lost when the browser tab is closed + */ + +import type { CalendarEvent } from '@calendar/shared'; +import { browser } from '$app/environment'; + +const STORAGE_KEY = 'calendar-session-events'; + +// Generate a unique ID for session events +function generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +// Load events from sessionStorage +function loadFromStorage(): CalendarEvent[] { + if (!browser) return []; + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +// Save events to sessionStorage +function saveToStorage(events: CalendarEvent[]) { + if (!browser) return; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(events)); + } catch (e) { + console.warn('Failed to save session events:', e); + } +} + +// State +let events = $state(loadFromStorage()); + +export const sessionEventsStore = { + get events() { + return events; + }, + + get hasEvents() { + return events.length > 0; + }, + + /** + * Initialize from sessionStorage (call on mount) + */ + initialize() { + events = loadFromStorage(); + }, + + /** + * Create a new session event + */ + createEvent(data: Partial): CalendarEvent { + const newEvent: CalendarEvent = { + id: generateSessionId(), + calendarId: data.calendarId || 'session-calendar', + userId: 'guest', + title: data.title || 'Neuer Termin', + description: data.description || null, + location: data.location || null, + startTime: data.startTime || new Date().toISOString(), + endTime: data.endTime || new Date().toISOString(), + isAllDay: data.isAllDay || false, + timezone: data.timezone || null, + recurrenceRule: null, + recurrenceEndDate: null, + recurrenceExceptions: null, + parentEventId: null, + color: data.color || null, + status: 'confirmed', + externalId: null, + metadata: data.metadata || null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as CalendarEvent; + + events = [...events, newEvent]; + saveToStorage(events); + return newEvent; + }, + + /** + * Update a session event + */ + updateEvent(id: string, data: Partial): CalendarEvent | null { + const index = events.findIndex((e) => e.id === id); + if (index === -1) return null; + + const updatedEvent = { + ...events[index], + ...data, + updatedAt: new Date().toISOString(), + }; + + events = events.map((e) => (e.id === id ? updatedEvent : e)); + saveToStorage(events); + return updatedEvent; + }, + + /** + * Delete a session event + */ + deleteEvent(id: string): boolean { + const hadEvent = events.some((e) => e.id === id); + events = events.filter((e) => e.id !== id); + saveToStorage(events); + return hadEvent; + }, + + /** + * Get event by ID + */ + getById(id: string): CalendarEvent | undefined { + return events.find((e) => e.id === id); + }, + + /** + * Check if an event ID is a session event + */ + isSessionEvent(id: string): boolean { + return id.startsWith('session_'); + }, + + /** + * Get all events (for migration to cloud on login) + */ + getAllEvents(): CalendarEvent[] { + return [...events]; + }, + + /** + * Clear all session events (after migration or on explicit clear) + */ + clear() { + events = []; + if (browser) { + sessionStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Get count of session events + */ + get count() { + return events.length; + }, +}; diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 575544714..bbbee4d8e 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -66,8 +66,10 @@ import ViewModePillContextMenu from '$lib/components/calendar/ViewModePillContextMenu.svelte'; import StatsOverlay from '$lib/components/calendar/StatsOverlay.svelte'; import SettingsModal from '$lib/components/settings/SettingsModal.svelte'; + import AuthGateModal from '$lib/components/AuthGateModal.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; import { heatmapStore } from '$lib/stores/heatmap.svelte'; + import { sessionEventsStore } from '$lib/stores/session-events.svelte'; import type { CalendarViewType } from '@calendar/shared'; // App switcher items @@ -561,30 +563,53 @@ } }); - onMount(async () => { - // Redirect to login if not authenticated - if (!authStore.isAuthenticated) { - goto('/login'); - return; - } + // Auth gate modal state + let showAuthGateModal = $state(false); + let authGateAction = $state<'save' | 'sync' | 'feature'>('save'); + // Show auth gate modal (can be called from child components) + function showAuthGate(action: 'save' | 'sync' | 'feature' = 'save') { + authGateAction = action; + showAuthGateModal = true; + } + + // Session events indicator + let hasSessionEvents = $derived(sessionEventsStore.hasEvents); + let sessionEventCount = $derived(sessionEventsStore.count); + + onMount(async () => { // Initialize split-panel from URL/localStorage splitPanel.initialize(); // Initialize view state viewStore.initialize(); - // Load calendars, tags, and user settings + // Initialize session events for guest mode + sessionEventsStore.initialize(); + + // Load calendars and tags (works in both guest and authenticated mode) await calendarsStore.fetchCalendars(); - await eventTagsStore.fetchTags(); - await userSettings.load(); + + // Only fetch tags and user settings if authenticated + if (authStore.isAuthenticated) { + await eventTagsStore.fetchTags(); + await userSettings.load(); + + // Check for session events to migrate after login + if (eventsStore.hasSessionEvents) { + const defaultCalendar = calendarsStore.defaultCalendar; + await eventsStore.migrateSessionEvents(defaultCalendar?.id); + } + } // Note: Birthdays are loaded via reactive $effect when showBirthdays is enabled - // Redirect to start page if on root and a custom start page is set - const currentPath = window.location.pathname; - if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') { - goto(userSettings.startPage, { replaceState: true }); + // Redirect to start page if on root and a custom start page is set (only if authenticated) + if (authStore.isAuthenticated) { + const currentPath = window.location.pathname; + if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') { + goto(userSettings.startPage, { replaceState: true }); + } } // Initialize sidebar mode from localStorage @@ -617,6 +642,38 @@
+ + {#if !authStore.isAuthenticated} +
+
+ + + + + Gast-Modus + {#if sessionEventCount > 0} + - {sessionEventCount} + {sessionEventCount === 1 ? 'Termin' : 'Termine'} lokal gespeichert + {:else} + - Termine werden nur in diesem Tab gespeichert + {/if} + +
+ +
+ {/if} {#if !settingsStore.immersiveModeEnabled} + + (showAuthGateModal = false)} + action={authGateAction} +/> + diff --git a/apps/chat/apps/web/src/lib/stores/conversations.svelte.ts b/apps/chat/apps/web/src/lib/stores/conversations.svelte.ts index a55e9653f..7a12cf6e7 100644 --- a/apps/chat/apps/web/src/lib/stores/conversations.svelte.ts +++ b/apps/chat/apps/web/src/lib/stores/conversations.svelte.ts @@ -1,9 +1,12 @@ /** * Conversations Store - Manages conversation list using Svelte 5 runes + * Supports both authenticated (cloud) and guest (session) modes */ import { conversationService } from '$lib/services/conversation'; import { toastStore } from './toast.svelte'; +import { sessionConversationsStore } from './session-conversations.svelte'; +import { authStore } from './auth.svelte'; import type { Conversation } from '@chat/types'; // State @@ -40,11 +43,20 @@ export const conversationsStore = { /** * Load conversations (userId is derived from JWT on backend) + * In guest mode, loads from session storage */ async loadConversations(spaceId?: string) { isLoading = true; error = null; + // Guest mode: load from session storage + if (!authStore.isAuthenticated) { + conversations = sessionConversationsStore.conversations; + isLoading = false; + return; + } + + // Authenticated: fetch from API try { conversations = await conversationService.getConversations(spaceId); } catch (e) { @@ -205,4 +217,53 @@ export const conversationsStore = { archivedConversations = []; error = null; }, + + /** + * Get session conversation count (for guest mode banner) + */ + get sessionConversationCount(): number { + return sessionConversationsStore.count; + }, + + /** + * Check if there are session conversations + */ + get hasSessionConversations(): boolean { + return sessionConversationsStore.count > 0; + }, + + /** + * Migrate session conversations to cloud after login + * Note: This is a placeholder - actual implementation would need backend support + */ + async migrateSessionConversations(): Promise { + if (!authStore.isAuthenticated) return; + + const sessionData = sessionConversationsStore.getAllConversations(); + if (sessionData.conversations.length === 0) return; + + // For now, we just clear the session data + // In a full implementation, you would create each conversation via API + // and transfer the messages + console.log( + 'Session conversations would be migrated:', + sessionData.conversations.length, + 'conversations' + ); + + // Clear session data after migration + sessionConversationsStore.clear(); + + // Reload conversations from server + await this.loadConversations(); + + toastStore.success('Unterhaltungen wurden in deinen Account übertragen'); + }, + + /** + * Check if a conversation ID is a session conversation + */ + isSessionConversation(id: string): boolean { + return sessionConversationsStore.isSessionConversation(id); + }, }; diff --git a/apps/chat/apps/web/src/lib/stores/session-conversations.svelte.ts b/apps/chat/apps/web/src/lib/stores/session-conversations.svelte.ts new file mode 100644 index 000000000..0247e53e0 --- /dev/null +++ b/apps/chat/apps/web/src/lib/stores/session-conversations.svelte.ts @@ -0,0 +1,183 @@ +/** + * Session Conversations Store - Manages conversations in sessionStorage for guest users + * This allows users to try the app without signing in. + * Data is stored in sessionStorage (lost when tab closes). + */ + +import type { Conversation, Message } from '@chat/types'; + +const CONVERSATIONS_KEY = 'chat-session-conversations'; +const MESSAGES_KEY = 'chat-session-messages'; + +// State +let conversations = $state([]); +let messages = $state>({}); + +// Generate session ID +function generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +// Load from sessionStorage +function loadFromStorage(): void { + if (typeof window === 'undefined') return; + + try { + const storedConversations = sessionStorage.getItem(CONVERSATIONS_KEY); + if (storedConversations) { + conversations = JSON.parse(storedConversations); + } + + const storedMessages = sessionStorage.getItem(MESSAGES_KEY); + if (storedMessages) { + messages = JSON.parse(storedMessages); + } + } catch (e) { + console.error('Failed to load session conversations:', e); + } +} + +// Save to sessionStorage +function saveToStorage(): void { + if (typeof window === 'undefined') return; + + try { + sessionStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(conversations)); + sessionStorage.setItem(MESSAGES_KEY, JSON.stringify(messages)); + } catch (e) { + console.error('Failed to save session conversations:', e); + } +} + +// Initialize on load +if (typeof window !== 'undefined') { + loadFromStorage(); +} + +export const sessionConversationsStore = { + // Getters + get conversations() { + return conversations; + }, + + /** + * Get messages for a conversation + */ + getMessages(conversationId: string): Message[] { + return messages[conversationId] || []; + }, + + /** + * Create a new session conversation + */ + createConversation(data: { modelId: string; templateId?: string; title?: string }): Conversation { + const now = new Date().toISOString(); + const conversation: Conversation = { + id: generateSessionId(), + userId: 'guest', + modelId: data.modelId, + templateId: data.templateId, + conversationMode: 'free', + documentMode: false, + title: data.title || 'Neue Unterhaltung', + isArchived: false, + isPinned: false, + createdAt: now, + updatedAt: now, + }; + + conversations = [conversation, ...conversations]; + messages[conversation.id] = []; + saveToStorage(); + + return conversation; + }, + + /** + * Add a message to a conversation + */ + addMessage( + conversationId: string, + data: { + sender: 'user' | 'assistant' | 'system'; + messageText: string; + } + ): Message { + const now = new Date().toISOString(); + const message: Message = { + id: generateSessionId(), + conversationId, + sender: data.sender, + messageText: data.messageText, + createdAt: now, + }; + + if (!messages[conversationId]) { + messages[conversationId] = []; + } + messages[conversationId] = [...messages[conversationId], message]; + + // Update conversation timestamp + conversations = conversations.map((c) => + c.id === conversationId ? { ...c, updatedAt: now } : c + ); + + saveToStorage(); + return message; + }, + + /** + * Update a conversation + */ + updateConversation(id: string, updates: Partial): void { + conversations = conversations.map((c) => + c.id === id ? { ...c, ...updates, updatedAt: new Date().toISOString() } : c + ); + saveToStorage(); + }, + + /** + * Delete a conversation + */ + deleteConversation(id: string): void { + conversations = conversations.filter((c) => c.id !== id); + delete messages[id]; + saveToStorage(); + }, + + /** + * Check if ID is a session conversation + */ + isSessionConversation(id: string): boolean { + return id.startsWith('session_'); + }, + + /** + * Get all conversations for migration + */ + getAllConversations(): { conversations: Conversation[]; messages: Record } { + return { + conversations: [...conversations], + messages: { ...messages }, + }; + }, + + /** + * Clear all session data + */ + clear(): void { + conversations = []; + messages = {}; + if (typeof window !== 'undefined') { + sessionStorage.removeItem(CONVERSATIONS_KEY); + sessionStorage.removeItem(MESSAGES_KEY); + } + }, + + /** + * Get count of session conversations + */ + get count(): number { + return conversations.length; + }, +}; diff --git a/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte b/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte index 74e946977..312d281c4 100644 --- a/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { locale } from 'svelte-i18n'; + import { browser } from '$app/environment'; import { LoginPage } from '@manacore/shared-auth-ui'; import { getLoginTranslations } from '@manacore/shared-i18n'; import { ChatLogo } from '@manacore/shared-branding'; @@ -10,8 +11,23 @@ import LanguageSelector from '$lib/components/LanguageSelector.svelte'; import '$lib/i18n'; - // Get redirect URL from query params - const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/chat'); + // Get redirect URL from query params or sessionStorage (set by AuthGateModal in guest mode) + const redirectTo = $derived.by(() => { + const queryRedirect = $page.url.searchParams.get('redirectTo'); + if (queryRedirect) return queryRedirect; + + // Check sessionStorage for return URL (from guest mode) + if (browser) { + const sessionRedirect = sessionStorage.getItem('auth-return-url'); + if (sessionRedirect) { + // Clear it after reading + sessionStorage.removeItem('auth-return-url'); + return sessionRedirect; + } + } + + return '/chat'; + }); // Get translations based on current locale const translations = $derived(getLoginTranslations($locale || 'de')); diff --git a/apps/chat/apps/web/src/routes/(auth)/register/+page.svelte b/apps/chat/apps/web/src/routes/(auth)/register/+page.svelte index 35dc40839..ad70a98e7 100644 --- a/apps/chat/apps/web/src/routes/(auth)/register/+page.svelte +++ b/apps/chat/apps/web/src/routes/(auth)/register/+page.svelte @@ -1,6 +1,7 @@ + +{#if open} + + +{/if} + + diff --git a/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts b/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts index 79e79bc30..5feef5fe6 100644 --- a/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts @@ -1,8 +1,11 @@ /** * Alarms Store - Manages alarm state using Svelte 5 runes + * Supports both authenticated (cloud) and guest (session) modes */ import { api } from '$lib/api/client'; +import { sessionAlarmsStore } from './session-alarms.svelte'; +import { authStore } from './auth.svelte'; import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared'; // State @@ -27,11 +30,20 @@ export const alarmsStore = { /** * Fetch all alarms from the backend + * In guest mode, loads from session storage */ async fetchAlarms() { loading = true; error = null; + // Guest mode: load from session storage + if (!authStore.isAuthenticated) { + alarms = sessionAlarmsStore.alarms; + loading = false; + return { success: true }; + } + + // Authenticated: fetch from API const response = await api.get('/alarms'); if (response.error) { @@ -47,8 +59,17 @@ export const alarmsStore = { /** * Create a new alarm + * In guest mode, creates in session storage */ 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 }; + } + + // Authenticated: create via API const response = await api.post('/alarms', input); if (response.error) { @@ -63,8 +84,20 @@ export const alarmsStore = { /** * Update an alarm + * In guest mode, updates in session storage */ 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 }; + } + return { success: false, error: 'Alarm not found' }; + } + + // Authenticated: update via API const response = await api.patch(`/alarms/${id}`, input); if (response.error) { @@ -89,8 +122,17 @@ export const alarmsStore = { /** * Delete an alarm + * In guest mode, deletes from session storage */ async deleteAlarm(id: string) { + // Guest mode: delete from session storage + if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) { + sessionAlarmsStore.deleteAlarm(id); + alarms = alarms.filter((a) => a.id !== id); + return { success: true }; + } + + // Authenticated: delete via API const response = await api.delete(`/alarms/${id}`); if (response.error) { @@ -108,4 +150,58 @@ export const alarmsStore = { alarms = []; error = null; }, + + /** + * Get session alarm count (for guest mode banner) + */ + get sessionAlarmCount(): number { + return sessionAlarmsStore.count; + }, + + /** + * Check if there are session alarms + */ + get hasSessionAlarms(): boolean { + return sessionAlarmsStore.count > 0; + }, + + /** + * Migrate session alarms to cloud after login + */ + async migrateSessionAlarms(): Promise { + 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('/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(); + }, + + /** + * Check if an alarm ID is a session alarm + */ + isSessionAlarm(id: string): boolean { + return sessionAlarmsStore.isSessionAlarm(id); + }, }; diff --git a/apps/clock/apps/web/src/lib/stores/session-alarms.svelte.ts b/apps/clock/apps/web/src/lib/stores/session-alarms.svelte.ts new file mode 100644 index 000000000..04e334e34 --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/session-alarms.svelte.ts @@ -0,0 +1,150 @@ +/** + * Session Alarms Store - Manages alarms in sessionStorage for guest users + * This allows users to try the app without signing in. + * Data is stored in sessionStorage (lost when tab closes). + */ + +import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared'; + +const STORAGE_KEY = 'clock-session-alarms'; + +// State +let alarms = $state([]); + +// Generate session ID +function generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +// Load from sessionStorage +function loadFromStorage(): void { + if (typeof window === 'undefined') return; + + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + alarms = JSON.parse(stored); + } + } catch (e) { + console.error('Failed to load session alarms:', e); + } +} + +// Save to sessionStorage +function saveToStorage(): void { + if (typeof window === 'undefined') return; + + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(alarms)); + } catch (e) { + console.error('Failed to save session alarms:', e); + } +} + +// Initialize on load +if (typeof window !== 'undefined') { + loadFromStorage(); +} + +export const sessionAlarmsStore = { + // Getters + get alarms() { + return alarms; + }, + get enabledAlarms() { + return alarms.filter((a) => a.enabled); + }, + + /** + * Create a new session alarm + */ + createAlarm(input: CreateAlarmInput): Alarm { + const now = new Date().toISOString(); + const alarm: Alarm = { + id: generateSessionId(), + userId: 'guest', + 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, + createdAt: now, + updatedAt: now, + }; + + alarms = [...alarms, alarm]; + saveToStorage(); + + return alarm; + }, + + /** + * Update a session alarm + */ + updateAlarm(id: string, input: UpdateAlarmInput): Alarm | null { + const index = alarms.findIndex((a) => a.id === id); + if (index === -1) return null; + + const updated: Alarm = { + ...alarms[index], + ...input, + updatedAt: new Date().toISOString(), + }; + + alarms = alarms.map((a) => (a.id === id ? updated : a)); + saveToStorage(); + + return updated; + }, + + /** + * Toggle alarm enabled state + */ + toggleAlarm(id: string): Alarm | null { + const alarm = alarms.find((a) => a.id === id); + if (!alarm) return null; + + return this.updateAlarm(id, { enabled: !alarm.enabled }); + }, + + /** + * Delete a session alarm + */ + deleteAlarm(id: string): void { + alarms = alarms.filter((a) => a.id !== id); + saveToStorage(); + }, + + /** + * Check if ID is a session alarm + */ + isSessionAlarm(id: string): boolean { + return id.startsWith('session_'); + }, + + /** + * Get all alarms for migration + */ + getAllAlarms(): Alarm[] { + return [...alarms]; + }, + + /** + * Clear all session data + */ + clear(): void { + alarms = []; + if (typeof window !== 'undefined') { + sessionStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Get count of session alarms + */ + get count(): number { + return alarms.length; + }, +}; diff --git a/apps/clock/apps/web/src/lib/stores/session-timers.svelte.ts b/apps/clock/apps/web/src/lib/stores/session-timers.svelte.ts new file mode 100644 index 000000000..d2953a383 --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/session-timers.svelte.ts @@ -0,0 +1,214 @@ +/** + * Session Timers Store - Manages timers in sessionStorage for guest users + * This allows users to try the app without signing in. + * Data is stored in sessionStorage (lost when tab closes). + */ + +import type { Timer, CreateTimerInput, UpdateTimerInput, TimerStatus } from '@clock/shared'; + +const STORAGE_KEY = 'clock-session-timers'; + +// State +let timers = $state([]); + +// Generate session ID +function generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +// Load from sessionStorage +function loadFromStorage(): void { + if (typeof window === 'undefined') return; + + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + timers = JSON.parse(stored); + } + } catch (e) { + console.error('Failed to load session timers:', e); + } +} + +// Save to sessionStorage +function saveToStorage(): void { + if (typeof window === 'undefined') return; + + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(timers)); + } catch (e) { + console.error('Failed to save session timers:', e); + } +} + +// Initialize on load +if (typeof window !== 'undefined') { + loadFromStorage(); +} + +export const sessionTimersStore = { + // Getters + get timers() { + return timers; + }, + get activeTimers() { + return timers.filter((t) => t.status === 'running' || t.status === 'paused'); + }, + + /** + * Create a new session timer + */ + createTimer(input: CreateTimerInput): Timer { + const now = new Date().toISOString(); + const timer: Timer = { + id: generateSessionId(), + userId: 'guest', + label: input.label || null, + durationSeconds: input.durationSeconds, + remainingSeconds: input.durationSeconds, + status: 'idle' as TimerStatus, + startedAt: null, + pausedAt: null, + sound: input.sound || null, + createdAt: now, + updatedAt: now, + }; + + timers = [...timers, timer]; + saveToStorage(); + + return timer; + }, + + /** + * Update a session timer + */ + updateTimer(id: string, input: UpdateTimerInput): Timer | null { + const index = timers.findIndex((t) => t.id === id); + if (index === -1) return null; + + const updated: Timer = { + ...timers[index], + ...input, + updatedAt: new Date().toISOString(), + }; + + timers = timers.map((t) => (t.id === id ? updated : t)); + saveToStorage(); + + return updated; + }, + + /** + * Start a timer + */ + startTimer(id: string): Timer | null { + const timer = timers.find((t) => t.id === id); + if (!timer) return null; + + const now = new Date().toISOString(); + const updated: Timer = { + ...timer, + status: 'running', + startedAt: now, + pausedAt: null, + updatedAt: now, + }; + + timers = timers.map((t) => (t.id === id ? updated : t)); + saveToStorage(); + + return updated; + }, + + /** + * Pause a timer + */ + pauseTimer(id: string): Timer | null { + const timer = timers.find((t) => t.id === id); + if (!timer) return null; + + const now = new Date().toISOString(); + const updated: Timer = { + ...timer, + status: 'paused', + pausedAt: now, + updatedAt: now, + }; + + timers = timers.map((t) => (t.id === id ? updated : t)); + saveToStorage(); + + return updated; + }, + + /** + * Reset a timer + */ + resetTimer(id: string): Timer | null { + const timer = timers.find((t) => t.id === id); + if (!timer) return null; + + const now = new Date().toISOString(); + const updated: Timer = { + ...timer, + status: 'idle', + remainingSeconds: timer.durationSeconds, + startedAt: null, + pausedAt: null, + updatedAt: now, + }; + + timers = timers.map((t) => (t.id === id ? updated : t)); + saveToStorage(); + + return updated; + }, + + /** + * Update local timer state (for countdown display) + */ + updateLocalState(id: string, updates: Partial): void { + timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t)); + saveToStorage(); + }, + + /** + * Delete a session timer + */ + deleteTimer(id: string): void { + timers = timers.filter((t) => t.id !== id); + saveToStorage(); + }, + + /** + * Check if ID is a session timer + */ + isSessionTimer(id: string): boolean { + return id.startsWith('session_'); + }, + + /** + * Get all timers for migration + */ + getAllTimers(): Timer[] { + return [...timers]; + }, + + /** + * Clear all session data + */ + clear(): void { + timers = []; + if (typeof window !== 'undefined') { + sessionStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Get count of session timers + */ + get count(): number { + return timers.length; + }, +}; diff --git a/apps/clock/apps/web/src/lib/stores/timers.svelte.ts b/apps/clock/apps/web/src/lib/stores/timers.svelte.ts index 741e0e988..ef8f7331c 100644 --- a/apps/clock/apps/web/src/lib/stores/timers.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/timers.svelte.ts @@ -1,8 +1,11 @@ /** * Timers Store - Manages timer state using Svelte 5 runes + * Supports both authenticated (cloud) and guest (session) modes */ import { api } from '$lib/api/client'; +import { sessionTimersStore } from './session-timers.svelte'; +import { authStore } from './auth.svelte'; import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared'; // State @@ -27,11 +30,20 @@ export const timersStore = { /** * Fetch all timers from the backend + * In guest mode, loads from session storage */ async fetchTimers() { loading = true; error = null; + // Guest mode: load from session storage + if (!authStore.isAuthenticated) { + timers = sessionTimersStore.timers; + loading = false; + return { success: true }; + } + + // Authenticated: fetch from API const response = await api.get('/timers'); if (response.error) { @@ -47,8 +59,17 @@ export const timersStore = { /** * Create a new timer + * In guest mode, creates in session storage */ async createTimer(input: CreateTimerInput) { + // Guest mode: create in session storage + if (!authStore.isAuthenticated) { + const timer = sessionTimersStore.createTimer(input); + timers = [...timers, timer]; + return { success: true, data: timer }; + } + + // Authenticated: create via API const response = await api.post('/timers', input); if (response.error) { @@ -63,8 +84,20 @@ export const timersStore = { /** * Update a timer + * In guest mode, updates in session storage */ async updateTimer(id: string, input: UpdateTimerInput) { + // Guest mode: update in session storage + if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { + const timer = sessionTimersStore.updateTimer(id, input); + if (timer) { + timers = timers.map((t) => (t.id === id ? timer : t)); + return { success: true, data: timer }; + } + return { success: false, error: 'Timer not found' }; + } + + // Authenticated: update via API const response = await api.patch(`/timers/${id}`, input); if (response.error) { @@ -79,8 +112,20 @@ export const timersStore = { /** * Start a timer + * In guest mode, starts in session storage */ async startTimer(id: string) { + // Guest mode: start in session storage + if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { + const timer = sessionTimersStore.startTimer(id); + if (timer) { + timers = timers.map((t) => (t.id === id ? timer : t)); + return { success: true, data: timer }; + } + return { success: false, error: 'Timer not found' }; + } + + // Authenticated: start via API const response = await api.post(`/timers/${id}/start`); if (response.error) { @@ -95,8 +140,20 @@ export const timersStore = { /** * Pause a timer + * In guest mode, pauses in session storage */ async pauseTimer(id: string) { + // Guest mode: pause in session storage + if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { + const timer = sessionTimersStore.pauseTimer(id); + if (timer) { + timers = timers.map((t) => (t.id === id ? timer : t)); + return { success: true, data: timer }; + } + return { success: false, error: 'Timer not found' }; + } + + // Authenticated: pause via API const response = await api.post(`/timers/${id}/pause`); if (response.error) { @@ -111,8 +168,20 @@ export const timersStore = { /** * Reset a timer + * In guest mode, resets in session storage */ async resetTimer(id: string) { + // Guest mode: reset in session storage + if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { + const timer = sessionTimersStore.resetTimer(id); + if (timer) { + timers = timers.map((t) => (t.id === id ? timer : t)); + return { success: true, data: timer }; + } + return { success: false, error: 'Timer not found' }; + } + + // Authenticated: reset via API const response = await api.post(`/timers/${id}/reset`); if (response.error) { @@ -127,8 +196,17 @@ export const timersStore = { /** * Delete a timer + * In guest mode, deletes from session storage */ async deleteTimer(id: string) { + // Guest mode: delete from session storage + if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) { + sessionTimersStore.deleteTimer(id); + timers = timers.filter((t) => t.id !== id); + return { success: true }; + } + + // Authenticated: delete via API const response = await api.delete(`/timers/${id}`); if (response.error) { @@ -153,4 +231,54 @@ export const timersStore = { timers = []; error = null; }, + + /** + * Get session timer count (for guest mode banner) + */ + get sessionTimerCount(): number { + return sessionTimersStore.count; + }, + + /** + * Check if there are session timers + */ + get hasSessionTimers(): boolean { + return sessionTimersStore.count > 0; + }, + + /** + * Migrate session timers to cloud after login + */ + async migrateSessionTimers(): Promise { + if (!authStore.isAuthenticated) return; + + const sessionTimers = sessionTimersStore.getAllTimers(); + if (sessionTimers.length === 0) return; + + // Create each timer via API + for (const timer of sessionTimers) { + try { + await api.post('/timers', { + label: timer.label, + durationSeconds: timer.durationSeconds, + sound: timer.sound, + }); + } catch (e) { + console.error('Failed to migrate timer:', e); + } + } + + // Clear session data after migration + sessionTimersStore.clear(); + + // Reload timers from server + await this.fetchTimers(); + }, + + /** + * Check if a timer ID is a session timer + */ + isSessionTimer(id: string): boolean { + return sessionTimersStore.isSessionTimer(id); + }, }; diff --git a/apps/clock/apps/web/src/routes/(app)/+layout.svelte b/apps/clock/apps/web/src/routes/(app)/+layout.svelte index d58c91855..9dd20e459 100644 --- a/apps/clock/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/clock/apps/web/src/routes/(app)/+layout.svelte @@ -13,6 +13,10 @@ import { theme } from '$lib/stores/theme.svelte'; import { authStore } from '$lib/stores/auth.svelte'; 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, @@ -29,6 +33,7 @@ import { setLocale, supportedLocales } from '$lib/i18n'; import { alarmsApi } from '$lib/api/alarms'; import { timersApi } from '$lib/api/timers'; + import AuthGateModal from '$lib/components/AuthGateModal.svelte'; // App switcher items const appItems = getPillAppItems('clock'); @@ -113,6 +118,14 @@ let isSidebarMode = $state(false); let isCollapsed = $state(false); + // Guest mode state + let showAuthGateModal = $state(false); + let authGateAction = $state<'save' | 'sync' | 'feature'>('save'); + + // Check if in guest mode + let isGuestMode = $derived(!authStore.isAuthenticated); + let sessionItemCount = $derived(sessionAlarmsStore.count + sessionTimersStore.count); + // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -239,21 +252,6 @@ } onMount(async () => { - // Redirect to login if not authenticated - if (!authStore.isAuthenticated) { - goto('/login'); - return; - } - - // Load user settings (includes start page preference) - await userSettings.load(); - - // Redirect to start page if on root and a custom start page is set - const currentPath = window.location.pathname; - if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') { - goto(userSettings.startPage, { replaceState: true }); - } - // Initialize sidebar mode from localStorage const savedSidebar = localStorage.getItem('clock-nav-sidebar'); if (savedSidebar === 'true') { @@ -267,12 +265,45 @@ isCollapsed = true; collapsedStore.set(true); } + + // Load user settings if authenticated + if (authStore.isAuthenticated) { + await userSettings.load(); + + // Check for session data to migrate + if (alarmsStore.hasSessionAlarms) { + await alarmsStore.migrateSessionAlarms(); + } + if (timersStore.hasSessionTimers) { + await timersStore.migrateSessionTimers(); + } + + // Redirect to start page if on root and a custom start page is set + const currentPath = window.location.pathname; + if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') { + goto(userSettings.startPage, { replaceState: true }); + } + } }); -
+ +{#if isGuestMode} +
+ + Du bist im Gast-Modus. + {#if sessionItemCount > 0} + {sessionItemCount} + {sessionItemCount === 1 ? 'Element' : 'Elemente'} in dieser Session. + {/if} + + +
+{/if} + +
+ + + (showAuthGateModal = false)} + />