mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
✨ feat(web): add session-first guest mode to all live apps
Users can now use Calendar, Chat, Clock, and Todo without signing in. Data is stored in sessionStorage (lost when tab closes). Changes per app: - Add session storage stores for temporary data - Add AuthGateModal for login prompts - Remove auth redirect from app layouts - Add guest mode banner with item count - Add sessionStorage return URL handling When users sign in, session data is migrated to their cloud account.
This commit is contained in:
parent
8248a70094
commit
3aeb88d772
30 changed files with 2829 additions and 84 deletions
167
apps/calendar/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
167
apps/calendar/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { sessionEventsStore } from '$lib/stores/session-events.svelte';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
action?: 'save' | 'sync' | 'feature';
|
||||
featureName?: string;
|
||||
}
|
||||
|
||||
let { visible, onClose, action = 'save', featureName = '' }: Props = $props();
|
||||
|
||||
// Action-specific messages
|
||||
const messages = {
|
||||
save: {
|
||||
title: 'Anmelden um zu speichern',
|
||||
description:
|
||||
'Melde dich an, um deine Termine in der Cloud zu speichern und auf allen Geräten zu synchronisieren.',
|
||||
icon: 'cloud',
|
||||
},
|
||||
sync: {
|
||||
title: 'Anmelden für Cloud-Sync',
|
||||
description:
|
||||
'Mit einem Account werden deine Termine automatisch synchronisiert und bleiben erhalten.',
|
||||
icon: 'refresh-cw',
|
||||
},
|
||||
feature: {
|
||||
title: `Anmelden für ${featureName}`,
|
||||
description: `Diese Funktion erfordert ein Konto. Melde dich an, um ${featureName} zu nutzen.`,
|
||||
icon: 'lock',
|
||||
},
|
||||
};
|
||||
|
||||
let currentMessage = $derived(messages[action]);
|
||||
let sessionEventCount = $derived(sessionEventsStore.count);
|
||||
|
||||
function handleLogin() {
|
||||
// Store return URL for redirect after login
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function handleRegister() {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/register');
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if visible}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
class="bg-card border-border mx-4 w-full max-w-md rounded-xl border p-6 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-gate-title"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
|
||||
>
|
||||
{#if currentMessage.icon === 'cloud'}
|
||||
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
{:else if currentMessage.icon === 'refresh-cw'}
|
||||
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 id="auth-gate-title" class="mb-2 text-center text-xl font-semibold">
|
||||
{currentMessage.title}
|
||||
</h2>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-muted-foreground mb-6 text-center text-sm">
|
||||
{currentMessage.description}
|
||||
</p>
|
||||
|
||||
<!-- Session events info -->
|
||||
{#if sessionEventCount > 0}
|
||||
<div class="bg-muted/50 mb-6 rounded-lg p-3 text-center text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
Du hast <strong class="text-foreground">{sessionEventCount}</strong>
|
||||
{sessionEventCount === 1 ? 'Termin' : 'Termine'} in dieser Sitzung erstellt.
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-muted-foreground text-xs">
|
||||
Diese werden nach der Anmeldung in deinen Account übernommen.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={handleLogin}
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 w-full rounded-lg px-4 py-3 font-medium transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
<button
|
||||
onclick={handleRegister}
|
||||
class="bg-secondary text-secondary-foreground hover:bg-secondary/80 w-full rounded-lg px-4 py-3 font-medium transition-colors"
|
||||
>
|
||||
Kostenloses Konto erstellen
|
||||
</button>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-muted-foreground hover:text-foreground w-full py-2 text-sm transition-colors"
|
||||
>
|
||||
Später
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info text -->
|
||||
<p class="text-muted-foreground mt-4 text-center text-xs">
|
||||
Du kannst weiterhin Termine erstellen. Diese werden lokal gespeichert und gehen beim
|
||||
Schließen des Tabs verloren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -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<Calendar[]>([]);
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<CalendarEvent[]>([]);
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
153
apps/calendar/apps/web/src/lib/stores/session-events.svelte.ts
Normal file
153
apps/calendar/apps/web/src/lib/stores/session-events.svelte.ts
Normal file
|
|
@ -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<CalendarEvent[]>(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>): 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>): 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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 @@
|
|||
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<!-- Guest Mode Banner -->
|
||||
{#if !authStore.isAuthenticated}
|
||||
<div
|
||||
class="guest-banner bg-primary/10 border-primary/20 fixed top-0 right-0 left-0 z-50 flex items-center justify-between border-b px-4 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<svg class="text-primary h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-foreground">
|
||||
<strong>Gast-Modus</strong>
|
||||
{#if sessionEventCount > 0}
|
||||
- {sessionEventCount}
|
||||
{sessionEventCount === 1 ? 'Termin' : 'Termine'} lokal gespeichert
|
||||
{:else}
|
||||
- Termine werden nur in diesem Tab gespeichert
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => showAuthGate('sync')}
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- UI Elements (hidden in immersive mode) -->
|
||||
{#if !settingsStore.immersiveModeEnabled}
|
||||
<PillNavigation
|
||||
|
|
@ -775,6 +832,13 @@
|
|||
{isSidebarMode}
|
||||
/>
|
||||
|
||||
<!-- Auth Gate Modal -->
|
||||
<AuthGateModal
|
||||
visible={showAuthGateModal}
|
||||
onClose={() => (showAuthGateModal = false)}
|
||||
action={authGateAction}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
|
|
@ -783,6 +847,21 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Guest banner styling */
|
||||
.guest-banner {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Offset content when guest banner is visible */
|
||||
.layout-container:has(.guest-banner) .main-content {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.layout-container:has(.guest-banner) .main-content.floating-mode {
|
||||
padding-top: calc(70px + 40px);
|
||||
}
|
||||
|
||||
/* Mobile: Fixed viewport, no scroll */
|
||||
@media (max-width: 768px) {
|
||||
.layout-container {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import { heatmapStore } from '$lib/stores/heatmap.svelte';
|
||||
|
|
@ -79,19 +77,14 @@
|
|||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch events for current view range
|
||||
// Fetch events for current view range (works in both guest and authenticated mode)
|
||||
await eventsStore.fetchEvents(viewStore.viewRange.start, viewStore.viewRange.end);
|
||||
initialized = true;
|
||||
});
|
||||
|
||||
// Refetch events when view changes
|
||||
$effect(() => {
|
||||
if (initialized && authStore.isAuthenticated) {
|
||||
if (initialized) {
|
||||
eventsStore.fetchEvents(viewStore.viewRange.start, viewStore.viewRange.end);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { CalendarLogo } 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') || '/');
|
||||
// Get redirect URL from query params or sessionStorage (set by AuthGateModal)
|
||||
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 '/';
|
||||
});
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getLoginTranslations($locale || 'de'));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { CalendarLogo } from '@manacore/shared-branding';
|
||||
|
|
@ -9,6 +10,19 @@
|
|||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get redirect URL from sessionStorage (set by AuthGateModal in guest mode)
|
||||
const redirectTo = $derived.by(() => {
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
// Clear it after reading
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
|
|
@ -27,7 +41,7 @@
|
|||
primaryColor="#0ea5e9"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
successRedirect={redirectTo}
|
||||
loginPath="/login"
|
||||
lightBackground="#e0f2fe"
|
||||
darkBackground="#0c1929"
|
||||
|
|
|
|||
230
apps/chat/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
230
apps/chat/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
action?: 'save' | 'sync' | 'ai' | 'feature';
|
||||
conversationCount?: number;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { open, action = 'ai', conversationCount = 0, onClose }: Props = $props();
|
||||
|
||||
// Messages based on action type
|
||||
const messages = {
|
||||
save: {
|
||||
title: 'Unterhaltungen speichern',
|
||||
description: 'Melde dich an, um deine Unterhaltungen dauerhaft in der Cloud zu speichern.',
|
||||
},
|
||||
sync: {
|
||||
title: 'Unterhaltungen synchronisieren',
|
||||
description: 'Melde dich an, um deine Unterhaltungen auf allen Geräten zu synchronisieren.',
|
||||
},
|
||||
ai: {
|
||||
title: 'KI-Antworten erhalten',
|
||||
description:
|
||||
'Um KI-Antworten zu erhalten, ist eine Anmeldung erforderlich. Dies ermöglicht uns, die Kosten für die KI-Verarbeitung zu verwalten.',
|
||||
},
|
||||
feature: {
|
||||
title: 'Funktion freischalten',
|
||||
description: 'Diese Funktion ist nur für angemeldete Benutzer verfügbar.',
|
||||
},
|
||||
};
|
||||
|
||||
const currentMessage = $derived(messages[action] || messages.ai);
|
||||
|
||||
function handleLogin() {
|
||||
if (browser) {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function handleRegister() {
|
||||
if (browser) {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/register');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={onClose}>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2>{currentMessage.title}</h2>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Schliessen">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>{currentMessage.description}</p>
|
||||
|
||||
{#if conversationCount > 0}
|
||||
<div class="migration-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
<span
|
||||
>Du hast {conversationCount}
|
||||
{conversationCount === 1 ? 'Unterhaltung' : 'Unterhaltungen'} in deiner Session. Diese
|
||||
werden nach der Anmeldung in deinen Account übertragen.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={onClose}> Später </button>
|
||||
<button class="btn btn-primary" onclick={handleLogin}> Anmelden </button>
|
||||
<button class="btn btn-outline" onclick={handleRegister}> Registrieren </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--color-background, white);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
max-width: 28rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground, #1f2937);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
border-radius: 0.375rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-foreground, #1f2937);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.migration-info {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--color-primary-50, #eff6ff);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary-700, #1d4ed8);
|
||||
}
|
||||
|
||||
.migration-info svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-600, #2563eb);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-muted, #f3f4f6);
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-muted-200, #e5e7eb);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
color: var(--color-foreground, #1f2937);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--color-muted, #f3f4f6);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<void> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Conversation[]>([]);
|
||||
let messages = $state<Record<string, Message[]>>({});
|
||||
|
||||
// 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<Conversation>): 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<string, Message[]> } {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { ChatLogo } from '@manacore/shared-branding';
|
||||
|
|
@ -9,6 +10,19 @@
|
|||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get redirect URL from sessionStorage (set by AuthGateModal in guest mode)
|
||||
const redirectTo = $derived.by(() => {
|
||||
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(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
|
|
@ -27,7 +41,7 @@
|
|||
primaryColor="#0ea5e9"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect="/chat"
|
||||
successRedirect={redirectTo}
|
||||
loginPath="/login"
|
||||
lightBackground="#e0f2fe"
|
||||
darkBackground="#0c1929"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
import { locale } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { sessionConversationsStore } from '$lib/stores/session-conversations.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
|
|
@ -22,6 +24,7 @@
|
|||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import AuthGateModal from '$lib/components/AuthGateModal.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
// App switcher items
|
||||
|
|
@ -33,6 +36,14 @@
|
|||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Guest mode state
|
||||
let showAuthGateModal = $state(false);
|
||||
let authGateAction = $state<'save' | 'sync' | 'ai' | 'feature'>('ai');
|
||||
|
||||
// Check if in guest mode
|
||||
let isGuestMode = $derived(!authStore.isAuthenticated);
|
||||
let sessionConversationCount = $derived(sessionConversationsStore.count);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
|
|
@ -151,7 +162,7 @@
|
|||
goto('/login');
|
||||
}
|
||||
|
||||
// Check auth on mount and redirect if not authenticated
|
||||
// Initialize on mount - supports both authenticated and guest mode
|
||||
onMount(async () => {
|
||||
// Initialize theme
|
||||
theme.initialize();
|
||||
|
|
@ -172,19 +183,20 @@
|
|||
|
||||
await authStore.initialize();
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
const redirectTo = encodeURIComponent(data.pathname || '/chat');
|
||||
goto(`/login?redirectTo=${redirectTo}`);
|
||||
return;
|
||||
}
|
||||
// Load user settings if authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
|
||||
// Load user settings
|
||||
await userSettings.load();
|
||||
// Check for session conversations to migrate
|
||||
if (conversationsStore.hasSessionConversations) {
|
||||
await conversationsStore.migrateSessionConversations();
|
||||
}
|
||||
|
||||
// Redirect to start page if on /chat and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/chat' && userSettings.startPage && userSettings.startPage !== '/chat') {
|
||||
goto(userSettings.startPage, { replaceState: true });
|
||||
// Redirect to start page if on /chat and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/chat' && userSettings.startPage && userSettings.startPage !== '/chat') {
|
||||
goto(userSettings.startPage, { replaceState: true });
|
||||
}
|
||||
}
|
||||
|
||||
isChecking = false;
|
||||
|
|
@ -204,8 +216,22 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Guest Mode Banner -->
|
||||
{#if isGuestMode}
|
||||
<div class="guest-banner">
|
||||
<span>
|
||||
Du bist im Gast-Modus.
|
||||
{#if sessionConversationCount > 0}
|
||||
{sessionConversationCount}
|
||||
{sessionConversationCount === 1 ? 'Unterhaltung' : 'Unterhaltungen'} in dieser Session.
|
||||
{/if}
|
||||
</span>
|
||||
<button onclick={() => goto('/login')}>Anmelden</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
<div class="layout-container" class:has-guest-banner={isGuestMode}>
|
||||
<!-- Floating/Sidebar Pill Navigation -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
|
|
@ -257,15 +283,59 @@
|
|||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Auth Gate Modal -->
|
||||
<AuthGateModal
|
||||
open={showAuthGateModal}
|
||||
action={authGateAction}
|
||||
conversationCount={sessionConversationCount}
|
||||
onClose={() => (showAuthGateModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.guest-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.guest-banner button {
|
||||
background-color: white;
|
||||
color: #3b82f6;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.guest-banner button:hover {
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout-container.has-guest-banner {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
transition: all 300ms ease;
|
||||
|
|
|
|||
225
apps/clock/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
225
apps/clock/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
action?: 'save' | 'sync' | 'feature';
|
||||
itemCount?: number;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { open, action = 'save', itemCount = 0, onClose }: Props = $props();
|
||||
|
||||
// Messages based on action type
|
||||
const messages = {
|
||||
save: {
|
||||
title: 'Daten speichern',
|
||||
description: 'Melde dich an, um deine Wecker und Timer dauerhaft in der Cloud zu speichern.',
|
||||
},
|
||||
sync: {
|
||||
title: 'Daten synchronisieren',
|
||||
description: 'Melde dich an, um deine Wecker und Timer auf allen Geräten zu synchronisieren.',
|
||||
},
|
||||
feature: {
|
||||
title: 'Funktion freischalten',
|
||||
description: 'Diese Funktion ist nur für angemeldete Benutzer verfügbar.',
|
||||
},
|
||||
};
|
||||
|
||||
const currentMessage = $derived(messages[action] || messages.save);
|
||||
|
||||
function handleLogin() {
|
||||
if (browser) {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function handleRegister() {
|
||||
if (browser) {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/register');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={onClose}>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2>{currentMessage.title}</h2>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Schliessen">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>{currentMessage.description}</p>
|
||||
|
||||
{#if itemCount > 0}
|
||||
<div class="migration-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
<span
|
||||
>Du hast {itemCount}
|
||||
{itemCount === 1 ? 'Element' : 'Elemente'} in deiner Session. Diese werden nach der Anmeldung
|
||||
in deinen Account übertragen.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={onClose}> Später </button>
|
||||
<button class="btn btn-primary" onclick={handleLogin}> Anmelden </button>
|
||||
<button class="btn btn-outline" onclick={handleRegister}> Registrieren </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--color-background, white);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
max-width: 28rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground, #1f2937);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
border-radius: 0.375rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-foreground, #1f2937);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.migration-info {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--color-primary-50, #fef3c7);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary-700, #b45309);
|
||||
}
|
||||
|
||||
.migration-info svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary, #f59e0b);
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-600, #d97706);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-muted, #f3f4f6);
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-muted-200, #e5e7eb);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
color: var(--color-foreground, #1f2937);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--color-muted, #f3f4f6);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<Alarm[]>('/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<Alarm>('/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<Alarm>(`/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<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();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an alarm ID is a session alarm
|
||||
*/
|
||||
isSessionAlarm(id: string): boolean {
|
||||
return sessionAlarmsStore.isSessionAlarm(id);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
150
apps/clock/apps/web/src/lib/stores/session-alarms.svelte.ts
Normal file
150
apps/clock/apps/web/src/lib/stores/session-alarms.svelte.ts
Normal file
|
|
@ -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<Alarm[]>([]);
|
||||
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
214
apps/clock/apps/web/src/lib/stores/session-timers.svelte.ts
Normal file
214
apps/clock/apps/web/src/lib/stores/session-timers.svelte.ts
Normal file
|
|
@ -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<Timer[]>([]);
|
||||
|
||||
// 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<Timer>): 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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<Timer[]>('/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<Timer>('/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<Timer>(`/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<Timer>(`/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<Timer>(`/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<Timer>(`/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<void> {
|
||||
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<Timer>('/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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="layout-container">
|
||||
<!-- Guest Mode Banner -->
|
||||
{#if isGuestMode}
|
||||
<div class="guest-banner">
|
||||
<span>
|
||||
Du bist im Gast-Modus.
|
||||
{#if sessionItemCount > 0}
|
||||
{sessionItemCount}
|
||||
{sessionItemCount === 1 ? 'Element' : 'Elemente'} in dieser Session.
|
||||
{/if}
|
||||
</span>
|
||||
<button onclick={() => goto('/login')}>Anmelden</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="layout-container" class:has-guest-banner={isGuestMode}>
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
|
|
@ -328,15 +359,59 @@
|
|||
emptyText="Keine Ergebnisse"
|
||||
searchingText="Suche..."
|
||||
/>
|
||||
|
||||
<!-- Auth Gate Modal -->
|
||||
<AuthGateModal
|
||||
open={showAuthGateModal}
|
||||
action={authGateAction}
|
||||
itemCount={sessionItemCount}
|
||||
onClose={() => (showAuthGateModal = false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.guest-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.guest-banner button {
|
||||
background-color: white;
|
||||
color: #f59e0b;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.guest-banner button:hover {
|
||||
background-color: #fef3c7;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout-container.has-guest-banner {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
transition: all 300ms ease;
|
||||
position: relative;
|
||||
|
|
|
|||
54
apps/clock/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
54
apps/clock/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// 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 '/';
|
||||
});
|
||||
|
||||
async function handleLogin(email: string, password: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
const result = await authStore.signIn(email, password);
|
||||
|
||||
if (result.success) {
|
||||
goto(redirectTo);
|
||||
} else {
|
||||
error = result.error || 'Anmeldung fehlgeschlagen';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginPage
|
||||
appName="Clock"
|
||||
appLogo=""
|
||||
{loading}
|
||||
{error}
|
||||
onSubmit={handleLogin}
|
||||
registerHref="/register"
|
||||
forgotPasswordHref="/forgot-password"
|
||||
/>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
|
@ -7,6 +8,19 @@
|
|||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Get redirect URL from sessionStorage (set by AuthGateModal in guest mode)
|
||||
const redirectTo = $derived.by(() => {
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
// Clear it after reading
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
|
||||
async function handleRegister(email: string, password: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
|
@ -18,7 +32,7 @@
|
|||
// Show verification message or redirect to verification page
|
||||
goto('/login?registered=true');
|
||||
} else {
|
||||
goto('/');
|
||||
goto(redirectTo);
|
||||
}
|
||||
} else {
|
||||
error = result.error || 'Registrierung fehlgeschlagen';
|
||||
|
|
|
|||
167
apps/todo/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
167
apps/todo/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { sessionTasksStore } from '$lib/stores/session-tasks.svelte';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
action?: 'save' | 'sync' | 'feature';
|
||||
featureName?: string;
|
||||
}
|
||||
|
||||
let { visible, onClose, action = 'save', featureName = '' }: Props = $props();
|
||||
|
||||
// Action-specific messages
|
||||
const messages = {
|
||||
save: {
|
||||
title: 'Anmelden um zu speichern',
|
||||
description:
|
||||
'Melde dich an, um deine Aufgaben in der Cloud zu speichern und auf allen Geräten zu synchronisieren.',
|
||||
icon: 'cloud',
|
||||
},
|
||||
sync: {
|
||||
title: 'Anmelden für Cloud-Sync',
|
||||
description:
|
||||
'Mit einem Account werden deine Aufgaben automatisch synchronisiert und bleiben erhalten.',
|
||||
icon: 'refresh-cw',
|
||||
},
|
||||
feature: {
|
||||
title: `Anmelden für ${featureName}`,
|
||||
description: `Diese Funktion erfordert ein Konto. Melde dich an, um ${featureName} zu nutzen.`,
|
||||
icon: 'lock',
|
||||
},
|
||||
};
|
||||
|
||||
let currentMessage = $derived(messages[action]);
|
||||
let sessionTaskCount = $derived(sessionTasksStore.count);
|
||||
|
||||
function handleLogin() {
|
||||
// Store return URL for redirect after login
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function handleRegister() {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/register');
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if visible}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
class="bg-card border-border mx-4 w-full max-w-md rounded-xl border p-6 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-gate-title"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
|
||||
>
|
||||
{#if currentMessage.icon === 'cloud'}
|
||||
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
{:else if currentMessage.icon === 'refresh-cw'}
|
||||
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 id="auth-gate-title" class="mb-2 text-center text-xl font-semibold">
|
||||
{currentMessage.title}
|
||||
</h2>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-muted-foreground mb-6 text-center text-sm">
|
||||
{currentMessage.description}
|
||||
</p>
|
||||
|
||||
<!-- Session tasks info -->
|
||||
{#if sessionTaskCount > 0}
|
||||
<div class="bg-muted/50 mb-6 rounded-lg p-3 text-center text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
Du hast <strong class="text-foreground">{sessionTaskCount}</strong>
|
||||
{sessionTaskCount === 1 ? 'Aufgabe' : 'Aufgaben'} in dieser Sitzung erstellt.
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-muted-foreground text-xs">
|
||||
Diese werden nach der Anmeldung in deinen Account übernommen.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={handleLogin}
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 w-full rounded-lg px-4 py-3 font-medium transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
<button
|
||||
onclick={handleRegister}
|
||||
class="bg-secondary text-secondary-foreground hover:bg-secondary/80 w-full rounded-lg px-4 py-3 font-medium transition-colors"
|
||||
>
|
||||
Kostenloses Konto erstellen
|
||||
</button>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-muted-foreground hover:text-foreground w-full py-2 text-sm transition-colors"
|
||||
>
|
||||
Später
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info text -->
|
||||
<p class="text-muted-foreground mt-4 text-center text-xs">
|
||||
Du kannst weiterhin Aufgaben erstellen. Diese werden lokal gespeichert und gehen beim
|
||||
Schließen des Tabs verloren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,9 +1,24 @@
|
|||
/**
|
||||
* Projects Store - Manages project state using Svelte 5 runes
|
||||
* Supports both authenticated (cloud) and guest (session) modes
|
||||
*/
|
||||
|
||||
import type { Project } from '@todo/shared';
|
||||
import * as projectsApi from '$lib/api/projects';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// Guest inbox project for unauthenticated users
|
||||
const GUEST_INBOX: Project = {
|
||||
id: 'session-inbox',
|
||||
userId: 'guest',
|
||||
name: 'Inbox',
|
||||
color: '#6b7280',
|
||||
order: 0,
|
||||
isArchived: false,
|
||||
isDefault: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// State
|
||||
let projects = $state<Project[]>([]);
|
||||
|
|
@ -45,10 +60,20 @@ export const projectsStore = {
|
|||
|
||||
/**
|
||||
* Fetch all projects from API
|
||||
* In guest mode, returns a default inbox project
|
||||
*/
|
||||
async fetchProjects() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Guest mode: return local inbox only
|
||||
if (!authStore.isAuthenticated) {
|
||||
projects = [GUEST_INBOX];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
try {
|
||||
projects = await projectsApi.getProjects();
|
||||
} catch (e) {
|
||||
|
|
@ -170,4 +195,18 @@ export const projectsStore = {
|
|||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a project ID is the guest inbox
|
||||
*/
|
||||
isGuestInbox(id: string) {
|
||||
return id === GUEST_INBOX.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the guest inbox ID
|
||||
*/
|
||||
get guestInboxId() {
|
||||
return GUEST_INBOX.id;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
190
apps/todo/apps/web/src/lib/stores/session-tasks.svelte.ts
Normal file
190
apps/todo/apps/web/src/lib/stores/session-tasks.svelte.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* Session Tasks Store - Temporary local tasks for guest users
|
||||
* Tasks are stored in sessionStorage and lost when the browser tab is closed
|
||||
*/
|
||||
|
||||
import type { Task, TaskPriority, Subtask } from '@todo/shared';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const STORAGE_KEY = 'todo-session-tasks';
|
||||
|
||||
// Generate a unique ID for session tasks
|
||||
function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
// Load tasks from sessionStorage
|
||||
function loadFromStorage(): Task[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save tasks to sessionStorage
|
||||
function saveToStorage(tasks: Task[]) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save session tasks:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// State
|
||||
let tasks = $state<Task[]>(loadFromStorage());
|
||||
|
||||
export const sessionTasksStore = {
|
||||
get tasks() {
|
||||
return tasks;
|
||||
},
|
||||
|
||||
get hasTaskks() {
|
||||
return tasks.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize from sessionStorage (call on mount)
|
||||
*/
|
||||
initialize() {
|
||||
tasks = loadFromStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new session task
|
||||
*/
|
||||
createTask(data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
dueDate?: string;
|
||||
priority?: TaskPriority;
|
||||
subtasks?: Subtask[];
|
||||
}): Task {
|
||||
const newTask: Task = {
|
||||
id: generateSessionId(),
|
||||
projectId: data.projectId || 'session-inbox',
|
||||
userId: 'guest',
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
dueDate: data.dueDate || null,
|
||||
priority: data.priority || 'medium',
|
||||
status: 'pending',
|
||||
isCompleted: false,
|
||||
order: tasks.length,
|
||||
subtasks: data.subtasks || null,
|
||||
labels: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
tasks = [...tasks, newTask];
|
||||
saveToStorage(tasks);
|
||||
return newTask;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a session task
|
||||
*/
|
||||
updateTask(id: string, data: Partial<Task>): Task | null {
|
||||
const index = tasks.findIndex((t) => t.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const updatedTask = {
|
||||
...tasks[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
saveToStorage(tasks);
|
||||
return updatedTask;
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete a session task
|
||||
*/
|
||||
completeTask(id: string): Task | null {
|
||||
return this.updateTask(id, {
|
||||
isCompleted: true,
|
||||
status: 'completed',
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Uncomplete a session task
|
||||
*/
|
||||
uncompleteTask(id: string): Task | null {
|
||||
return this.updateTask(id, {
|
||||
isCompleted: false,
|
||||
status: 'pending',
|
||||
completedAt: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a session task
|
||||
*/
|
||||
deleteTask(id: string): boolean {
|
||||
const hadTask = tasks.some((t) => t.id === id);
|
||||
tasks = tasks.filter((t) => t.id !== id);
|
||||
saveToStorage(tasks);
|
||||
return hadTask;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get task by ID
|
||||
*/
|
||||
getById(id: string): Task | undefined {
|
||||
return tasks.find((t) => t.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a task ID is a session task
|
||||
*/
|
||||
isSessionTask(id: string): boolean {
|
||||
return id.startsWith('session_');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all tasks (for migration to cloud on login)
|
||||
*/
|
||||
getAllTasks(): Task[] {
|
||||
return [...tasks];
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all session tasks (after migration or on explicit clear)
|
||||
*/
|
||||
clear() {
|
||||
tasks = [];
|
||||
if (browser) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of session tasks
|
||||
*/
|
||||
get count() {
|
||||
return tasks.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get incomplete tasks
|
||||
*/
|
||||
get incompleteTasks() {
|
||||
return tasks.filter((t) => !t.isCompleted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get completed tasks
|
||||
*/
|
||||
get completedTasks() {
|
||||
return tasks.filter((t) => t.isCompleted);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
/**
|
||||
* Tasks Store - Manages task state using Svelte 5 runes
|
||||
* Supports both authenticated (cloud) and guest (session) modes
|
||||
*/
|
||||
|
||||
import type { Task, TaskPriority, TaskStatus, Subtask } from '@todo/shared';
|
||||
import * as tasksApi from '$lib/api/tasks';
|
||||
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
|
||||
import { sessionTasksStore } from './session-tasks.svelte';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// State
|
||||
let tasks = $state<Task[]>([]);
|
||||
|
|
@ -114,10 +117,21 @@ export const tasksStore = {
|
|||
|
||||
/**
|
||||
* Fetch all tasks (incomplete + completed) for unified view
|
||||
* In guest mode, only shows session tasks
|
||||
*/
|
||||
async fetchAllTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Guest mode: load session tasks only
|
||||
if (!authStore.isAuthenticated) {
|
||||
sessionTasksStore.initialize();
|
||||
tasks = sessionTasksStore.tasks;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
try {
|
||||
// Fetch all tasks without filter - let frontend handle filtering
|
||||
const allTasks = await tasksApi.getTasks({});
|
||||
|
|
@ -187,6 +201,7 @@ export const tasksStore = {
|
|||
|
||||
/**
|
||||
* Create a new task
|
||||
* If not authenticated, creates a session task (local only)
|
||||
*/
|
||||
async createTask(data: {
|
||||
title: string;
|
||||
|
|
@ -199,6 +214,22 @@ export const tasksStore = {
|
|||
recurrenceRule?: string;
|
||||
}) {
|
||||
error = null;
|
||||
|
||||
// Guest mode: create session task
|
||||
if (!authStore.isAuthenticated) {
|
||||
const sessionTask = sessionTasksStore.createTask({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
projectId: data.projectId || 'session-inbox',
|
||||
dueDate: data.dueDate,
|
||||
priority: data.priority,
|
||||
subtasks: data.subtasks as Subtask[],
|
||||
});
|
||||
tasks = [...tasks, sessionTask];
|
||||
return sessionTask;
|
||||
}
|
||||
|
||||
// Authenticated: create via API
|
||||
try {
|
||||
const newTask = await tasksApi.createTask(data);
|
||||
tasks = [...tasks, newTask];
|
||||
|
|
@ -212,6 +243,7 @@ export const tasksStore = {
|
|||
|
||||
/**
|
||||
* Update an existing task
|
||||
* Handles both session tasks (local) and cloud tasks
|
||||
*/
|
||||
async updateTask(
|
||||
id: string,
|
||||
|
|
@ -235,6 +267,18 @@ export const tasksStore = {
|
|||
}
|
||||
) {
|
||||
error = null;
|
||||
|
||||
// Session task: update locally
|
||||
if (sessionTasksStore.isSessionTask(id)) {
|
||||
const updated = sessionTasksStore.updateTask(id, data);
|
||||
if (updated) {
|
||||
tasks = tasks.map((t) => (t.id === id ? updated : t));
|
||||
return updated;
|
||||
}
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
// Cloud task: update via API
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateTask(id, data);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
|
|
@ -289,9 +333,19 @@ export const tasksStore = {
|
|||
|
||||
/**
|
||||
* Delete a task
|
||||
* Handles both session tasks (local) and cloud tasks
|
||||
*/
|
||||
async deleteTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Session task: delete locally
|
||||
if (sessionTasksStore.isSessionTask(id)) {
|
||||
sessionTasksStore.deleteTask(id);
|
||||
tasks = tasks.filter((t) => t.id !== id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloud task: delete via API
|
||||
try {
|
||||
await tasksApi.deleteTask(id);
|
||||
tasks = tasks.filter((t) => t.id !== id);
|
||||
|
|
@ -304,9 +358,22 @@ export const tasksStore = {
|
|||
|
||||
/**
|
||||
* Mark task as complete
|
||||
* Handles both session tasks (local) and cloud tasks
|
||||
*/
|
||||
async completeTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Session task: complete locally
|
||||
if (sessionTasksStore.isSessionTask(id)) {
|
||||
const completed = sessionTasksStore.completeTask(id);
|
||||
if (completed) {
|
||||
tasks = tasks.map((t) => (t.id === id ? completed : t));
|
||||
return completed;
|
||||
}
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
// Cloud task: complete via API
|
||||
try {
|
||||
const completedTask = await tasksApi.completeTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? completedTask : t));
|
||||
|
|
@ -320,9 +387,22 @@ export const tasksStore = {
|
|||
|
||||
/**
|
||||
* Mark task as incomplete
|
||||
* Handles both session tasks (local) and cloud tasks
|
||||
*/
|
||||
async uncompleteTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Session task: uncomplete locally
|
||||
if (sessionTasksStore.isSessionTask(id)) {
|
||||
const uncompleted = sessionTasksStore.uncompleteTask(id);
|
||||
if (uncompleted) {
|
||||
tasks = tasks.map((t) => (t.id === id ? uncompleted : t));
|
||||
return uncompleted;
|
||||
}
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
// Cloud task: uncomplete via API
|
||||
try {
|
||||
const uncompletedTask = await tasksApi.uncompleteTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? uncompletedTask : t));
|
||||
|
|
@ -409,4 +489,65 @@ export const tasksStore = {
|
|||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a task is a session task (local only)
|
||||
*/
|
||||
isSessionTask(taskId: string) {
|
||||
return sessionTasksStore.isSessionTask(taskId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Migrate session tasks to cloud after login
|
||||
* Call this after successful authentication
|
||||
*/
|
||||
async migrateSessionTasks(defaultProjectId?: string) {
|
||||
const sessionTasks = sessionTasksStore.getAllTasks();
|
||||
if (sessionTasks.length === 0) return { migrated: 0, failed: 0 };
|
||||
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const sessionTask of sessionTasks) {
|
||||
try {
|
||||
await tasksApi.createTask({
|
||||
title: sessionTask.title,
|
||||
description: sessionTask.description || undefined,
|
||||
projectId: defaultProjectId || undefined,
|
||||
dueDate: sessionTask.dueDate ? String(sessionTask.dueDate) : undefined,
|
||||
priority: sessionTask.priority,
|
||||
subtasks: sessionTask.subtasks?.map((s) => ({
|
||||
title: s.title,
|
||||
isCompleted: s.isCompleted,
|
||||
order: s.order,
|
||||
})),
|
||||
});
|
||||
migrated++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session tasks after migration
|
||||
if (migrated > 0) {
|
||||
sessionTasksStore.clear();
|
||||
console.log(`Migrated ${migrated} tasks to cloud`);
|
||||
}
|
||||
|
||||
return { migrated, failed };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of pending session tasks
|
||||
*/
|
||||
get sessionTaskCount() {
|
||||
return sessionTasksStore.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if there are pending session tasks to migrate
|
||||
*/
|
||||
get hasSessionTasks() {
|
||||
return sessionTasksStore.count > 0;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@
|
|||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { getTasks } from '$lib/api/tasks';
|
||||
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
|
||||
import AuthGateModal from '$lib/components/AuthGateModal.svelte';
|
||||
import { sessionTasksStore } from '$lib/stores/session-tasks.svelte';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('todo');
|
||||
|
|
@ -265,30 +267,47 @@
|
|||
goto('/login');
|
||||
}
|
||||
|
||||
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 tasks indicator
|
||||
let sessionTaskCount = $derived(sessionTasksStore.count);
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Initialize todo settings
|
||||
todoSettings.initialize();
|
||||
|
||||
// Load data
|
||||
await Promise.all([
|
||||
projectsStore.fetchProjects(),
|
||||
labelsStore.fetchLabels(),
|
||||
userSettings.load(),
|
||||
]);
|
||||
// Initialize session tasks for guest mode
|
||||
sessionTasksStore.initialize();
|
||||
|
||||
// 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 });
|
||||
// Load projects (works in both guest and authenticated mode)
|
||||
await projectsStore.fetchProjects();
|
||||
|
||||
// Only fetch labels and user settings if authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
await Promise.all([labelsStore.fetchLabels(), userSettings.load()]);
|
||||
|
||||
// Check for session tasks to migrate after login
|
||||
if (tasksStore.hasSessionTasks) {
|
||||
const defaultProject = projectsStore.inboxProject;
|
||||
await tasksStore.migrateSessionTasks(defaultProject?.id);
|
||||
}
|
||||
|
||||
// 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 (with error handling for private browsing)
|
||||
|
|
@ -344,6 +363,38 @@
|
|||
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<!-- Guest Mode Banner -->
|
||||
{#if !authStore.isAuthenticated}
|
||||
<div
|
||||
class="guest-banner bg-primary/10 border-primary/20 fixed top-0 right-0 left-0 z-50 flex items-center justify-between border-b px-4 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<svg class="text-primary h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-foreground">
|
||||
<strong>Gast-Modus</strong>
|
||||
{#if sessionTaskCount > 0}
|
||||
- {sessionTaskCount}
|
||||
{sessionTaskCount === 1 ? 'Aufgabe' : 'Aufgaben'} lokal gespeichert
|
||||
{:else}
|
||||
- Aufgaben werden nur in diesem Tab gespeichert
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => showAuthGate('sync')}
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- UI Elements (hidden in immersive mode) -->
|
||||
{#if !todoSettings.immersiveModeEnabled}
|
||||
<PillNavigation
|
||||
|
|
@ -421,7 +472,24 @@
|
|||
</div>
|
||||
</SplitPaneContainer>
|
||||
|
||||
<!-- Auth Gate Modal -->
|
||||
<AuthGateModal
|
||||
visible={showAuthGateModal}
|
||||
onClose={() => (showAuthGateModal = false)}
|
||||
action={authGateAction}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Guest banner styling */
|
||||
.guest-banner {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Offset content when guest banner is visible */
|
||||
.layout-container:has(.guest-banner) .main-content.floating-mode {
|
||||
padding-top: calc(70px + 40px);
|
||||
}
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { format, addDays, subDays, startOfDay } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { ListChecks } from '@manacore/shared-icons';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
|
|
@ -18,14 +16,10 @@
|
|||
let editingTask = $state<Task | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
viewStore.setToday();
|
||||
|
||||
try {
|
||||
// Fetch tasks (works in both guest and authenticated mode)
|
||||
await tasksStore.fetchAllTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
|
|
|
|||
|
|
@ -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 { TodoLogo } from '@manacore/shared-branding';
|
||||
|
|
@ -9,8 +10,23 @@
|
|||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
|
||||
// Get redirect URL from query params or sessionStorage (set by AuthGateModal)
|
||||
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 '/';
|
||||
});
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getLoginTranslations($locale || 'de'));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { TodoLogo } from '@manacore/shared-branding';
|
||||
|
|
@ -8,6 +9,19 @@
|
|||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
|
||||
// Get redirect URL from sessionStorage (set by AuthGateModal in guest mode)
|
||||
const redirectTo = $derived.by(() => {
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
// Clear it after reading
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
|
|
@ -26,7 +40,7 @@
|
|||
primaryColor="#8b5cf6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
successRedirect={redirectTo}
|
||||
loginPath="/login"
|
||||
lightBackground="#f3e8ff"
|
||||
darkBackground="#1e1b4b"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue