mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 11:06:43 +02:00
✨ feat(web): add session-first guest mode to all live apps
Users can now use Calendar, Chat, Clock, and Todo without signing in. Data is stored in sessionStorage (lost when tab closes). Changes per app: - Add session storage stores for temporary data - Add AuthGateModal for login prompts - Remove auth redirect from app layouts - Add guest mode banner with item count - Add sessionStorage return URL handling When users sign in, session data is migrated to their cloud account.
This commit is contained in:
parent
8248a70094
commit
3aeb88d772
30 changed files with 2829 additions and 84 deletions
225
apps/clock/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
225
apps/clock/apps/web/src/lib/components/AuthGateModal.svelte
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
action?: 'save' | 'sync' | 'feature';
|
||||
itemCount?: number;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { open, action = 'save', itemCount = 0, onClose }: Props = $props();
|
||||
|
||||
// Messages based on action type
|
||||
const messages = {
|
||||
save: {
|
||||
title: 'Daten speichern',
|
||||
description: 'Melde dich an, um deine Wecker und Timer dauerhaft in der Cloud zu speichern.',
|
||||
},
|
||||
sync: {
|
||||
title: 'Daten synchronisieren',
|
||||
description: 'Melde dich an, um deine Wecker und Timer auf allen Geräten zu synchronisieren.',
|
||||
},
|
||||
feature: {
|
||||
title: 'Funktion freischalten',
|
||||
description: 'Diese Funktion ist nur für angemeldete Benutzer verfügbar.',
|
||||
},
|
||||
};
|
||||
|
||||
const currentMessage = $derived(messages[action] || messages.save);
|
||||
|
||||
function handleLogin() {
|
||||
if (browser) {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function handleRegister() {
|
||||
if (browser) {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/register');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={onClose}>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2>{currentMessage.title}</h2>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Schliessen">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>{currentMessage.description}</p>
|
||||
|
||||
{#if itemCount > 0}
|
||||
<div class="migration-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
<span
|
||||
>Du hast {itemCount}
|
||||
{itemCount === 1 ? 'Element' : 'Elemente'} in deiner Session. Diese werden nach der Anmeldung
|
||||
in deinen Account übertragen.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={onClose}> Später </button>
|
||||
<button class="btn btn-primary" onclick={handleLogin}> Anmelden </button>
|
||||
<button class="btn btn-outline" onclick={handleRegister}> Registrieren </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--color-background, white);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
max-width: 28rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground, #1f2937);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
border-radius: 0.375rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-foreground, #1f2937);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.migration-info {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--color-primary-50, #fef3c7);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary-700, #b45309);
|
||||
}
|
||||
|
||||
.migration-info svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary, #f59e0b);
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-600, #d97706);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-muted, #f3f4f6);
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-muted-200, #e5e7eb);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
color: var(--color-foreground, #1f2937);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--color-muted, #f3f4f6);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
/**
|
||||
* Alarms Store - Manages alarm state using Svelte 5 runes
|
||||
* Supports both authenticated (cloud) and guest (session) modes
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import { sessionAlarmsStore } from './session-alarms.svelte';
|
||||
import { authStore } from './auth.svelte';
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
|
||||
// State
|
||||
|
|
@ -27,11 +30,20 @@ export const alarmsStore = {
|
|||
|
||||
/**
|
||||
* Fetch all alarms from the backend
|
||||
* In guest mode, loads from session storage
|
||||
*/
|
||||
async fetchAlarms() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Guest mode: load from session storage
|
||||
if (!authStore.isAuthenticated) {
|
||||
alarms = sessionAlarmsStore.alarms;
|
||||
loading = false;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
const response = await api.get<Alarm[]>('/alarms');
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -47,8 +59,17 @@ export const alarmsStore = {
|
|||
|
||||
/**
|
||||
* Create a new alarm
|
||||
* In guest mode, creates in session storage
|
||||
*/
|
||||
async createAlarm(input: CreateAlarmInput) {
|
||||
// Guest mode: create in session storage
|
||||
if (!authStore.isAuthenticated) {
|
||||
const alarm = sessionAlarmsStore.createAlarm(input);
|
||||
alarms = [...alarms, alarm];
|
||||
return { success: true, data: alarm };
|
||||
}
|
||||
|
||||
// Authenticated: create via API
|
||||
const response = await api.post<Alarm>('/alarms', input);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -63,8 +84,20 @@ export const alarmsStore = {
|
|||
|
||||
/**
|
||||
* Update an alarm
|
||||
* In guest mode, updates in session storage
|
||||
*/
|
||||
async updateAlarm(id: string, input: UpdateAlarmInput) {
|
||||
// Guest mode: update in session storage
|
||||
if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) {
|
||||
const alarm = sessionAlarmsStore.updateAlarm(id, input);
|
||||
if (alarm) {
|
||||
alarms = alarms.map((a) => (a.id === id ? alarm : a));
|
||||
return { success: true, data: alarm };
|
||||
}
|
||||
return { success: false, error: 'Alarm not found' };
|
||||
}
|
||||
|
||||
// Authenticated: update via API
|
||||
const response = await api.patch<Alarm>(`/alarms/${id}`, input);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -89,8 +122,17 @@ export const alarmsStore = {
|
|||
|
||||
/**
|
||||
* Delete an alarm
|
||||
* In guest mode, deletes from session storage
|
||||
*/
|
||||
async deleteAlarm(id: string) {
|
||||
// Guest mode: delete from session storage
|
||||
if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) {
|
||||
sessionAlarmsStore.deleteAlarm(id);
|
||||
alarms = alarms.filter((a) => a.id !== id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Authenticated: delete via API
|
||||
const response = await api.delete(`/alarms/${id}`);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -108,4 +150,58 @@ export const alarmsStore = {
|
|||
alarms = [];
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get session alarm count (for guest mode banner)
|
||||
*/
|
||||
get sessionAlarmCount(): number {
|
||||
return sessionAlarmsStore.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if there are session alarms
|
||||
*/
|
||||
get hasSessionAlarms(): boolean {
|
||||
return sessionAlarmsStore.count > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Migrate session alarms to cloud after login
|
||||
*/
|
||||
async migrateSessionAlarms(): Promise<void> {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
|
||||
const sessionAlarms = sessionAlarmsStore.getAllAlarms();
|
||||
if (sessionAlarms.length === 0) return;
|
||||
|
||||
// Create each alarm via API
|
||||
for (const alarm of sessionAlarms) {
|
||||
try {
|
||||
await api.post<Alarm>('/alarms', {
|
||||
label: alarm.label,
|
||||
time: alarm.time,
|
||||
enabled: alarm.enabled,
|
||||
repeatDays: alarm.repeatDays,
|
||||
snoozeMinutes: alarm.snoozeMinutes,
|
||||
sound: alarm.sound,
|
||||
vibrate: alarm.vibrate,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate alarm:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session data after migration
|
||||
sessionAlarmsStore.clear();
|
||||
|
||||
// Reload alarms from server
|
||||
await this.fetchAlarms();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an alarm ID is a session alarm
|
||||
*/
|
||||
isSessionAlarm(id: string): boolean {
|
||||
return sessionAlarmsStore.isSessionAlarm(id);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
150
apps/clock/apps/web/src/lib/stores/session-alarms.svelte.ts
Normal file
150
apps/clock/apps/web/src/lib/stores/session-alarms.svelte.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Session Alarms Store - Manages alarms in sessionStorage for guest users
|
||||
* This allows users to try the app without signing in.
|
||||
* Data is stored in sessionStorage (lost when tab closes).
|
||||
*/
|
||||
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
|
||||
const STORAGE_KEY = 'clock-session-alarms';
|
||||
|
||||
// State
|
||||
let alarms = $state<Alarm[]>([]);
|
||||
|
||||
// Generate session ID
|
||||
function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
// Load from sessionStorage
|
||||
function loadFromStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
alarms = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load session alarms:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to sessionStorage
|
||||
function saveToStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(alarms));
|
||||
} catch (e) {
|
||||
console.error('Failed to save session alarms:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
if (typeof window !== 'undefined') {
|
||||
loadFromStorage();
|
||||
}
|
||||
|
||||
export const sessionAlarmsStore = {
|
||||
// Getters
|
||||
get alarms() {
|
||||
return alarms;
|
||||
},
|
||||
get enabledAlarms() {
|
||||
return alarms.filter((a) => a.enabled);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new session alarm
|
||||
*/
|
||||
createAlarm(input: CreateAlarmInput): Alarm {
|
||||
const now = new Date().toISOString();
|
||||
const alarm: Alarm = {
|
||||
id: generateSessionId(),
|
||||
userId: 'guest',
|
||||
label: input.label || null,
|
||||
time: input.time,
|
||||
enabled: input.enabled ?? true,
|
||||
repeatDays: input.repeatDays || null,
|
||||
snoozeMinutes: input.snoozeMinutes || null,
|
||||
sound: input.sound || null,
|
||||
vibrate: input.vibrate ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
alarms = [...alarms, alarm];
|
||||
saveToStorage();
|
||||
|
||||
return alarm;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a session alarm
|
||||
*/
|
||||
updateAlarm(id: string, input: UpdateAlarmInput): Alarm | null {
|
||||
const index = alarms.findIndex((a) => a.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const updated: Alarm = {
|
||||
...alarms[index],
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
alarms = alarms.map((a) => (a.id === id ? updated : a));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle alarm enabled state
|
||||
*/
|
||||
toggleAlarm(id: string): Alarm | null {
|
||||
const alarm = alarms.find((a) => a.id === id);
|
||||
if (!alarm) return null;
|
||||
|
||||
return this.updateAlarm(id, { enabled: !alarm.enabled });
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a session alarm
|
||||
*/
|
||||
deleteAlarm(id: string): void {
|
||||
alarms = alarms.filter((a) => a.id !== id);
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if ID is a session alarm
|
||||
*/
|
||||
isSessionAlarm(id: string): boolean {
|
||||
return id.startsWith('session_');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all alarms for migration
|
||||
*/
|
||||
getAllAlarms(): Alarm[] {
|
||||
return [...alarms];
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all session data
|
||||
*/
|
||||
clear(): void {
|
||||
alarms = [];
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of session alarms
|
||||
*/
|
||||
get count(): number {
|
||||
return alarms.length;
|
||||
},
|
||||
};
|
||||
214
apps/clock/apps/web/src/lib/stores/session-timers.svelte.ts
Normal file
214
apps/clock/apps/web/src/lib/stores/session-timers.svelte.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Session Timers Store - Manages timers in sessionStorage for guest users
|
||||
* This allows users to try the app without signing in.
|
||||
* Data is stored in sessionStorage (lost when tab closes).
|
||||
*/
|
||||
|
||||
import type { Timer, CreateTimerInput, UpdateTimerInput, TimerStatus } from '@clock/shared';
|
||||
|
||||
const STORAGE_KEY = 'clock-session-timers';
|
||||
|
||||
// State
|
||||
let timers = $state<Timer[]>([]);
|
||||
|
||||
// Generate session ID
|
||||
function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
// Load from sessionStorage
|
||||
function loadFromStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
timers = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load session timers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to sessionStorage
|
||||
function saveToStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(timers));
|
||||
} catch (e) {
|
||||
console.error('Failed to save session timers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
if (typeof window !== 'undefined') {
|
||||
loadFromStorage();
|
||||
}
|
||||
|
||||
export const sessionTimersStore = {
|
||||
// Getters
|
||||
get timers() {
|
||||
return timers;
|
||||
},
|
||||
get activeTimers() {
|
||||
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new session timer
|
||||
*/
|
||||
createTimer(input: CreateTimerInput): Timer {
|
||||
const now = new Date().toISOString();
|
||||
const timer: Timer = {
|
||||
id: generateSessionId(),
|
||||
userId: 'guest',
|
||||
label: input.label || null,
|
||||
durationSeconds: input.durationSeconds,
|
||||
remainingSeconds: input.durationSeconds,
|
||||
status: 'idle' as TimerStatus,
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
sound: input.sound || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
timers = [...timers, timer];
|
||||
saveToStorage();
|
||||
|
||||
return timer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a session timer
|
||||
*/
|
||||
updateTimer(id: string, input: UpdateTimerInput): Timer | null {
|
||||
const index = timers.findIndex((t) => t.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const updated: Timer = {
|
||||
...timers[index],
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
timers = timers.map((t) => (t.id === id ? updated : t));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a timer
|
||||
*/
|
||||
startTimer(id: string): Timer | null {
|
||||
const timer = timers.find((t) => t.id === id);
|
||||
if (!timer) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: Timer = {
|
||||
...timer,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
timers = timers.map((t) => (t.id === id ? updated : t));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause a timer
|
||||
*/
|
||||
pauseTimer(id: string): Timer | null {
|
||||
const timer = timers.find((t) => t.id === id);
|
||||
if (!timer) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: Timer = {
|
||||
...timer,
|
||||
status: 'paused',
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
timers = timers.map((t) => (t.id === id ? updated : t));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset a timer
|
||||
*/
|
||||
resetTimer(id: string): Timer | null {
|
||||
const timer = timers.find((t) => t.id === id);
|
||||
if (!timer) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: Timer = {
|
||||
...timer,
|
||||
status: 'idle',
|
||||
remainingSeconds: timer.durationSeconds,
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
timers = timers.map((t) => (t.id === id ? updated : t));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update local timer state (for countdown display)
|
||||
*/
|
||||
updateLocalState(id: string, updates: Partial<Timer>): void {
|
||||
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a session timer
|
||||
*/
|
||||
deleteTimer(id: string): void {
|
||||
timers = timers.filter((t) => t.id !== id);
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if ID is a session timer
|
||||
*/
|
||||
isSessionTimer(id: string): boolean {
|
||||
return id.startsWith('session_');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all timers for migration
|
||||
*/
|
||||
getAllTimers(): Timer[] {
|
||||
return [...timers];
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all session data
|
||||
*/
|
||||
clear(): void {
|
||||
timers = [];
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of session timers
|
||||
*/
|
||||
get count(): number {
|
||||
return timers.length;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
/**
|
||||
* Timers Store - Manages timer state using Svelte 5 runes
|
||||
* Supports both authenticated (cloud) and guest (session) modes
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import { sessionTimersStore } from './session-timers.svelte';
|
||||
import { authStore } from './auth.svelte';
|
||||
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
||||
|
||||
// State
|
||||
|
|
@ -27,11 +30,20 @@ export const timersStore = {
|
|||
|
||||
/**
|
||||
* Fetch all timers from the backend
|
||||
* In guest mode, loads from session storage
|
||||
*/
|
||||
async fetchTimers() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Guest mode: load from session storage
|
||||
if (!authStore.isAuthenticated) {
|
||||
timers = sessionTimersStore.timers;
|
||||
loading = false;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
const response = await api.get<Timer[]>('/timers');
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -47,8 +59,17 @@ export const timersStore = {
|
|||
|
||||
/**
|
||||
* Create a new timer
|
||||
* In guest mode, creates in session storage
|
||||
*/
|
||||
async createTimer(input: CreateTimerInput) {
|
||||
// Guest mode: create in session storage
|
||||
if (!authStore.isAuthenticated) {
|
||||
const timer = sessionTimersStore.createTimer(input);
|
||||
timers = [...timers, timer];
|
||||
return { success: true, data: timer };
|
||||
}
|
||||
|
||||
// Authenticated: create via API
|
||||
const response = await api.post<Timer>('/timers', input);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -63,8 +84,20 @@ export const timersStore = {
|
|||
|
||||
/**
|
||||
* Update a timer
|
||||
* In guest mode, updates in session storage
|
||||
*/
|
||||
async updateTimer(id: string, input: UpdateTimerInput) {
|
||||
// Guest mode: update in session storage
|
||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
||||
const timer = sessionTimersStore.updateTimer(id, input);
|
||||
if (timer) {
|
||||
timers = timers.map((t) => (t.id === id ? timer : t));
|
||||
return { success: true, data: timer };
|
||||
}
|
||||
return { success: false, error: 'Timer not found' };
|
||||
}
|
||||
|
||||
// Authenticated: update via API
|
||||
const response = await api.patch<Timer>(`/timers/${id}`, input);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -79,8 +112,20 @@ export const timersStore = {
|
|||
|
||||
/**
|
||||
* Start a timer
|
||||
* In guest mode, starts in session storage
|
||||
*/
|
||||
async startTimer(id: string) {
|
||||
// Guest mode: start in session storage
|
||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
||||
const timer = sessionTimersStore.startTimer(id);
|
||||
if (timer) {
|
||||
timers = timers.map((t) => (t.id === id ? timer : t));
|
||||
return { success: true, data: timer };
|
||||
}
|
||||
return { success: false, error: 'Timer not found' };
|
||||
}
|
||||
|
||||
// Authenticated: start via API
|
||||
const response = await api.post<Timer>(`/timers/${id}/start`);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -95,8 +140,20 @@ export const timersStore = {
|
|||
|
||||
/**
|
||||
* Pause a timer
|
||||
* In guest mode, pauses in session storage
|
||||
*/
|
||||
async pauseTimer(id: string) {
|
||||
// Guest mode: pause in session storage
|
||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
||||
const timer = sessionTimersStore.pauseTimer(id);
|
||||
if (timer) {
|
||||
timers = timers.map((t) => (t.id === id ? timer : t));
|
||||
return { success: true, data: timer };
|
||||
}
|
||||
return { success: false, error: 'Timer not found' };
|
||||
}
|
||||
|
||||
// Authenticated: pause via API
|
||||
const response = await api.post<Timer>(`/timers/${id}/pause`);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -111,8 +168,20 @@ export const timersStore = {
|
|||
|
||||
/**
|
||||
* Reset a timer
|
||||
* In guest mode, resets in session storage
|
||||
*/
|
||||
async resetTimer(id: string) {
|
||||
// Guest mode: reset in session storage
|
||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
||||
const timer = sessionTimersStore.resetTimer(id);
|
||||
if (timer) {
|
||||
timers = timers.map((t) => (t.id === id ? timer : t));
|
||||
return { success: true, data: timer };
|
||||
}
|
||||
return { success: false, error: 'Timer not found' };
|
||||
}
|
||||
|
||||
// Authenticated: reset via API
|
||||
const response = await api.post<Timer>(`/timers/${id}/reset`);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -127,8 +196,17 @@ export const timersStore = {
|
|||
|
||||
/**
|
||||
* Delete a timer
|
||||
* In guest mode, deletes from session storage
|
||||
*/
|
||||
async deleteTimer(id: string) {
|
||||
// Guest mode: delete from session storage
|
||||
if (!authStore.isAuthenticated || sessionTimersStore.isSessionTimer(id)) {
|
||||
sessionTimersStore.deleteTimer(id);
|
||||
timers = timers.filter((t) => t.id !== id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Authenticated: delete via API
|
||||
const response = await api.delete(`/timers/${id}`);
|
||||
|
||||
if (response.error) {
|
||||
|
|
@ -153,4 +231,54 @@ export const timersStore = {
|
|||
timers = [];
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get session timer count (for guest mode banner)
|
||||
*/
|
||||
get sessionTimerCount(): number {
|
||||
return sessionTimersStore.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if there are session timers
|
||||
*/
|
||||
get hasSessionTimers(): boolean {
|
||||
return sessionTimersStore.count > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Migrate session timers to cloud after login
|
||||
*/
|
||||
async migrateSessionTimers(): Promise<void> {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
|
||||
const sessionTimers = sessionTimersStore.getAllTimers();
|
||||
if (sessionTimers.length === 0) return;
|
||||
|
||||
// Create each timer via API
|
||||
for (const timer of sessionTimers) {
|
||||
try {
|
||||
await api.post<Timer>('/timers', {
|
||||
label: timer.label,
|
||||
durationSeconds: timer.durationSeconds,
|
||||
sound: timer.sound,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate timer:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session data after migration
|
||||
sessionTimersStore.clear();
|
||||
|
||||
// Reload timers from server
|
||||
await this.fetchTimers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a timer ID is a session timer
|
||||
*/
|
||||
isSessionTimer(id: string): boolean {
|
||||
return sessionTimersStore.isSessionTimer(id);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { alarmsStore } from '$lib/stores/alarms.svelte';
|
||||
import { timersStore } from '$lib/stores/timers.svelte';
|
||||
import { sessionAlarmsStore } from '$lib/stores/session-alarms.svelte';
|
||||
import { sessionTimersStore } from '$lib/stores/session-timers.svelte';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
|
|
@ -29,6 +33,7 @@
|
|||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { alarmsApi } from '$lib/api/alarms';
|
||||
import { timersApi } from '$lib/api/timers';
|
||||
import AuthGateModal from '$lib/components/AuthGateModal.svelte';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('clock');
|
||||
|
|
@ -113,6 +118,14 @@
|
|||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Guest mode state
|
||||
let showAuthGateModal = $state(false);
|
||||
let authGateAction = $state<'save' | 'sync' | 'feature'>('save');
|
||||
|
||||
// Check if in guest mode
|
||||
let isGuestMode = $derived(!authStore.isAuthenticated);
|
||||
let sessionItemCount = $derived(sessionAlarmsStore.count + sessionTimersStore.count);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
|
|
@ -239,21 +252,6 @@
|
|||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Redirect to login if not authenticated
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user settings (includes start page preference)
|
||||
await userSettings.load();
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
|
||||
goto(userSettings.startPage, { replaceState: true });
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('clock-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
|
|
@ -267,12 +265,45 @@
|
|||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
// Load user settings if authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
|
||||
// Check for session data to migrate
|
||||
if (alarmsStore.hasSessionAlarms) {
|
||||
await alarmsStore.migrateSessionAlarms();
|
||||
}
|
||||
if (timersStore.hasSessionTimers) {
|
||||
await timersStore.migrateSessionTimers();
|
||||
}
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
|
||||
goto(userSettings.startPage, { replaceState: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="layout-container">
|
||||
<!-- Guest Mode Banner -->
|
||||
{#if isGuestMode}
|
||||
<div class="guest-banner">
|
||||
<span>
|
||||
Du bist im Gast-Modus.
|
||||
{#if sessionItemCount > 0}
|
||||
{sessionItemCount}
|
||||
{sessionItemCount === 1 ? 'Element' : 'Elemente'} in dieser Session.
|
||||
{/if}
|
||||
</span>
|
||||
<button onclick={() => goto('/login')}>Anmelden</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="layout-container" class:has-guest-banner={isGuestMode}>
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
|
|
@ -328,15 +359,59 @@
|
|||
emptyText="Keine Ergebnisse"
|
||||
searchingText="Suche..."
|
||||
/>
|
||||
|
||||
<!-- Auth Gate Modal -->
|
||||
<AuthGateModal
|
||||
open={showAuthGateModal}
|
||||
action={authGateAction}
|
||||
itemCount={sessionItemCount}
|
||||
onClose={() => (showAuthGateModal = false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.guest-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.guest-banner button {
|
||||
background-color: white;
|
||||
color: #f59e0b;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.guest-banner button:hover {
|
||||
background-color: #fef3c7;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout-container.has-guest-banner {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
transition: all 300ms ease;
|
||||
position: relative;
|
||||
|
|
|
|||
54
apps/clock/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
54
apps/clock/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Get redirect URL from query params or sessionStorage (set by AuthGateModal in guest mode)
|
||||
const redirectTo = $derived.by(() => {
|
||||
const queryRedirect = $page.url.searchParams.get('redirectTo');
|
||||
if (queryRedirect) return queryRedirect;
|
||||
|
||||
// Check sessionStorage for return URL (from guest mode)
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
// Clear it after reading
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
return '/';
|
||||
});
|
||||
|
||||
async function handleLogin(email: string, password: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
const result = await authStore.signIn(email, password);
|
||||
|
||||
if (result.success) {
|
||||
goto(redirectTo);
|
||||
} else {
|
||||
error = result.error || 'Anmeldung fehlgeschlagen';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginPage
|
||||
appName="Clock"
|
||||
appLogo=""
|
||||
{loading}
|
||||
{error}
|
||||
onSubmit={handleLogin}
|
||||
registerHref="/register"
|
||||
forgotPasswordHref="/forgot-password"
|
||||
/>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
|
@ -7,6 +8,19 @@
|
|||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Get redirect URL from sessionStorage (set by AuthGateModal in guest mode)
|
||||
const redirectTo = $derived.by(() => {
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
// Clear it after reading
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
|
||||
async function handleRegister(email: string, password: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
|
@ -18,7 +32,7 @@
|
|||
// Show verification message or redirect to verification page
|
||||
goto('/login?registered=true');
|
||||
} else {
|
||||
goto('/');
|
||||
goto(redirectTo);
|
||||
}
|
||||
} else {
|
||||
error = result.error || 'Registrierung fehlgeschlagen';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue