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