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:
Till-JS 2026-01-23 21:15:08 +01:00
parent 8248a70094
commit 3aeb88d772
30 changed files with 2829 additions and 84 deletions

View 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}

View file

@ -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;
},
};

View file

@ -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;
},
};

View 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;
},
};

View file

@ -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 {

View file

@ -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);
}
});

View file

@ -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'));

View file

@ -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"

View 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>

View file

@ -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);
},
};

View file

@ -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;
},
};

View file

@ -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'));

View file

@ -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"

View file

@ -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;

View 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>

View file

@ -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);
},
};

View 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;
},
};

View 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;
},
};

View file

@ -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);
},
};

View file

@ -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;

View 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"
/>

View file

@ -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';

View 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}

View file

@ -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;
},
};

View 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);
},
};

View file

@ -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;
},
};

View file

@ -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;

View file

@ -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);

View file

@ -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'));

View file

@ -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"