diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte index 9f1169c0e..81a8ac396 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte @@ -5,6 +5,7 @@ import { format } from 'date-fns'; import { de } from 'date-fns/locale'; import type { CalendarViewType } from '@calendar/shared'; + import { PillTimeRangeSelector, PillViewSwitcher } from '@manacore/shared-ui'; // View type labels const viewLabels: Record = { @@ -29,6 +30,22 @@ 'year', ]; + // Convert to ViewOptions for PillViewSwitcher + const viewOptions = visibleViews.map((type) => ({ + id: type, + label: viewLabels[type], + title: viewLabels[type], + })); + + // Hours change handlers + function handleStartHourChange(hour: number) { + settingsStore.set('dayStartHour', hour); + } + + function handleEndHourChange(hour: number) { + settingsStore.set('dayEndHour', hour); + } + // Format title based on view type let title = $derived.by(() => { const date = viewStore.currentDate; @@ -70,18 +87,24 @@ } }); - function handleViewChange(type: CalendarViewType) { - viewStore.setViewType(type); + function handleViewChange(type: string) { + viewStore.setViewType(type as CalendarViewType); }
- + -
@@ -144,9 +180,10 @@ align-items: center; justify-content: space-between; padding: 0.75rem 1rem; - background: hsl(var(--color-background)); - border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: transparent; transition: padding-left 300ms ease; + gap: 1rem; + flex-wrap: wrap; } .calendar-header.nav-collapsed { @@ -156,132 +193,188 @@ .header-left { display: flex; align-items: center; - gap: 0.5rem; - } - - .today-btn { - padding: 0.25rem 0.625rem; - border: 1px solid hsl(var(--color-border)); - background: transparent; - border-radius: var(--radius-sm); - font-size: 0.75rem; - font-weight: 500; - color: hsl(var(--color-foreground)); - cursor: pointer; - transition: all 150ms ease; - } - - .today-btn:hover { - background: hsl(var(--color-muted)); - } - - .nav-buttons { - display: flex; - gap: 0.125rem; - } - - .nav-btn { - padding: 0.25rem; - border: none; - background: transparent; - border-radius: var(--radius-sm); - color: hsl(var(--color-muted-foreground)); - cursor: pointer; - transition: all 150ms ease; - display: flex; - align-items: center; - justify-content: center; - } - - .nav-btn:hover { - background: hsl(var(--color-muted)); - color: hsl(var(--color-foreground)); + gap: 0.75rem; } .header-title { - font-size: 1.25rem; + font-size: 1.125rem; font-weight: 600; color: hsl(var(--color-foreground)); margin: 0; + white-space: nowrap; } .header-right { display: flex; align-items: center; gap: 0.75rem; + flex-wrap: wrap; } - .view-selector { + /* Glass pill base styles */ + .pill { display: flex; - background: hsl(var(--color-muted)); - border-radius: var(--radius-md); - padding: 0.125rem; - } - - .view-btn { - padding: 0.25rem 0.625rem; - border: none; - background: transparent; - border-radius: var(--radius-sm); - font-size: 0.75rem; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border-radius: 9999px; + font-size: 0.875rem; font-weight: 500; - color: hsl(var(--color-muted-foreground)); + white-space: nowrap; + text-decoration: none; + transition: all 0.2s; + border: none; cursor: pointer; - transition: all 150ms ease; } - .view-btn:hover { - color: hsl(var(--color-foreground)); + .glass-pill { + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(0, 0, 0, 0.1); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + color: #374151; } - .view-btn.active { - background: hsl(var(--color-background)); - color: hsl(var(--color-foreground)); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + :global(.dark) .glass-pill { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #f3f4f6; } - .filter-toggles { + .glass-pill:hover { + background: rgba(255, 255, 255, 0.95); + border-color: rgba(0, 0, 0, 0.15); + transform: translateY(-1px); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + } + + :global(.dark) .glass-pill:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.25); + } + + /* Today button */ + .today-btn { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + } + + /* Navigation buttons group */ + .nav-buttons { display: flex; - gap: 0.25rem; + align-items: center; + padding: 0; + gap: 0; } - .filter-toggle { - padding: 0.25rem 0.5rem; - border: 1px solid hsl(var(--color-border)); + .nav-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.5rem; background: transparent; - border-radius: var(--radius-sm); - font-size: 0.6875rem; - font-weight: 600; - color: hsl(var(--color-muted-foreground)); + border: none; cursor: pointer; - transition: all 150ms ease; + color: inherit; + transition: background 0.2s; } - .filter-toggle:hover { - background: hsl(var(--color-muted)); - color: hsl(var(--color-foreground)); + .nav-btn:first-child { + border-radius: 9999px 0 0 9999px; } - .filter-toggle.active { - background: hsl(var(--color-primary)); - color: hsl(var(--color-primary-foreground)); - border-color: hsl(var(--color-primary)); + .nav-btn:last-child { + border-radius: 0 9999px 9999px 0; } - @media (max-width: 640px) { + .nav-btn:hover { + background: rgba(0, 0, 0, 0.05); + } + + :global(.dark) .nav-btn:hover { + background: rgba(255, 255, 255, 0.1); + } + + .nav-icon { + width: 1rem; + height: 1rem; + } + + .nav-divider { + width: 1px; + height: 1rem; + background: rgba(0, 0, 0, 0.15); + } + + :global(.dark) .nav-divider { + background: rgba(255, 255, 255, 0.2); + } + + /* Filter pills */ + .filter-pills { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .filter-pill { + padding: 0.375rem 0.625rem; + font-size: 0.75rem; + } + + .filter-pill.active { + background: color-mix(in srgb, #3b82f6 20%, white 80%); + border-color: #3b82f6; + color: #3b82f6; + } + + :global(.dark) .filter-pill.active { + background: color-mix(in srgb, #3b82f6 30%, transparent 70%); + border-color: #3b82f6; + color: #3b82f6; + } + + .pill-icon { + width: 0.875rem; + height: 0.875rem; + } + + /* Responsive */ + @media (max-width: 900px) { .calendar-header { flex-direction: column; - gap: 0.5rem; - padding: 0.5rem; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; } .header-left { width: 100%; - justify-content: space-between; + justify-content: flex-start; + } + + .header-right { + width: 100%; + justify-content: flex-start; } + .header-title { + font-size: 1rem; + } + } + + @media (max-width: 640px) { .header-title { font-size: 0.875rem; } + + .filter-pills { + flex-wrap: wrap; + } } diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index 5415e0eb4..2caf29af9 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -4,7 +4,6 @@ import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; - import TodoRow from './TodoRow.svelte'; import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { @@ -688,16 +687,6 @@ {/if} - - {#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0} -
-
-
- -
-
- {/if} -
@@ -857,16 +846,6 @@ cursor: pointer; } - /* Todos section */ - .todos-section { - display: flex; - border-bottom: 1px solid hsl(var(--color-border) / 0.5); - } - - .todos-content { - flex: 1; - } - /* Block-style all-day events (displayed as full-day blocks in the grid) */ .all-day-block-event { position: absolute; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index 61b59f64a..d15615b38 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -4,7 +4,6 @@ import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; - import TodoRow from './TodoRow.svelte'; import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { @@ -830,18 +829,6 @@
{/if} - - {#if todosStore.serviceAvailable} -
-
- {#each days as day} -
- -
- {/each} -
- {/if} -
@@ -1043,18 +1030,6 @@ cursor: pointer; } - /* Todos row */ - .todos-row { - display: flex; - border-bottom: 1px solid hsl(var(--color-border) / 0.5); - } - - .todos-cell { - flex: 1; - border-left: 1px solid hsl(var(--color-border)); - min-height: 0; - } - /* Block-style all-day events (displayed as full-day blocks in the grid) */ .all-day-block-event { position: absolute; diff --git a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts index 7cd9e1568..bd9b1b372 100644 --- a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts @@ -1,10 +1,13 @@ /** * Settings Store - Manages user preferences for the calendar app - * Uses Svelte 5 runes and localStorage for persistence + * Uses Svelte 5 runes with: + * - localStorage for immediate persistence + * - userSettings store for cloud sync (device-specific) */ import { browser } from '$app/environment'; import type { CalendarViewType } from '@calendar/shared'; +import { userSettings } from './user-settings.svelte'; // Settings types export type WeekStartDay = 0 | 1; // 0 = Sunday, 1 = Monday @@ -78,6 +81,34 @@ function saveSettings(settings: CalendarAppSettings) { // State let settings = $state(loadSettings()); +let cloudSyncEnabled = $state(false); +let initialSyncDone = $state(false); + +/** + * Sync settings to cloud (device-specific) + */ +async function syncToCloud() { + if (!cloudSyncEnabled || !browser) return; + + try { + await userSettings.updateDeviceAppSettings(settings as unknown as Record); + } catch (e) { + console.error('Failed to sync calendar settings to cloud:', e); + } +} + +/** + * Load settings from cloud (device-specific) + */ +function loadFromCloud(): Partial | null { + if (!userSettings.loaded) return null; + + const cloudSettings = userSettings.currentDeviceAppSettings; + if (cloudSettings && Object.keys(cloudSettings).length > 0) { + return cloudSettings as unknown as Partial; + } + return null; +} export const settingsStore = { // Getters @@ -120,6 +151,36 @@ export const settingsStore = { get sidebarCollapsed() { return settings.sidebarCollapsed; }, + get cloudSyncEnabled() { + return cloudSyncEnabled; + }, + + /** + * Enable cloud sync and load settings from cloud + */ + enableCloudSync() { + cloudSyncEnabled = true; + + // On first sync, prefer cloud settings over local if they exist + if (!initialSyncDone) { + const cloudSettings = loadFromCloud(); + if (cloudSettings && Object.keys(cloudSettings).length > 0) { + settings = { ...DEFAULT_SETTINGS, ...settings, ...cloudSettings }; + saveSettings(settings); + } else { + // No cloud settings yet, push local settings to cloud + syncToCloud(); + } + initialSyncDone = true; + } + }, + + /** + * Disable cloud sync + */ + disableCloudSync() { + cloudSyncEnabled = false; + }, /** * Toggle sidebar collapsed state @@ -127,6 +188,7 @@ export const settingsStore = { toggleSidebar() { settings = { ...settings, sidebarCollapsed: !settings.sidebarCollapsed }; saveSettings(settings); + syncToCloud(); }, /** @@ -143,6 +205,7 @@ export const settingsStore = { set(key: K, value: CalendarAppSettings[K]) { settings = { ...settings, [key]: value }; saveSettings(settings); + syncToCloud(); }, /** @@ -151,6 +214,7 @@ export const settingsStore = { update(updates: Partial) { settings = { ...settings, ...updates }; saveSettings(settings); + syncToCloud(); }, /** @@ -159,6 +223,7 @@ export const settingsStore = { reset() { settings = { ...DEFAULT_SETTINGS }; saveSettings(settings); + syncToCloud(); }, /** diff --git a/apps/calendar/apps/web/src/routes/+layout.svelte b/apps/calendar/apps/web/src/routes/+layout.svelte index e0ed75e76..2256dee79 100644 --- a/apps/calendar/apps/web/src/routes/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/+layout.svelte @@ -5,6 +5,8 @@ import { onMount } from 'svelte'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; + import { userSettings } from '$lib/stores/user-settings.svelte'; + import { settingsStore } from '$lib/stores/settings.svelte'; import ToastContainer from '$lib/components/ToastContainer.svelte'; import { AppLoadingSkeleton } from '$lib/components/skeletons'; @@ -24,6 +26,18 @@ loading = false; }); + + // Load user settings when authenticated + $effect(() => { + if (authStore.isAuthenticated) { + userSettings.load().then(() => { + // Enable cloud sync for calendar settings after user settings are loaded + settingsStore.enableCloudSync(); + }); + } else { + settingsStore.disableCloudSync(); + } + }); diff --git a/packages/shared-theme/src/types.ts b/packages/shared-theme/src/types.ts index 964771bec..0ff3d8dfe 100644 --- a/packages/shared-theme/src/types.ts +++ b/packages/shared-theme/src/types.ts @@ -302,12 +302,46 @@ export interface AppOverride { theme?: Partial; } +/** + * Device type for device-specific settings + */ +export type DeviceType = 'desktop' | 'mobile' | 'tablet'; + +/** + * Device-specific app settings + */ +export interface DeviceAppSettings { + deviceName: string; + deviceType: DeviceType; + lastSeen: string; + apps: Record>; +} + +/** + * Device info for listing + */ +export interface DeviceInfo { + deviceId: string; + deviceName: string; + deviceType: DeviceType; + lastSeen: string; + appCount: number; +} + /** * Full user settings response from API */ export interface UserSettingsResponse { globalSettings: GlobalSettings; appOverrides: Record; + deviceSettings: Record; +} + +/** + * Devices list response + */ +export interface DevicesListResponse { + devices: DeviceInfo[]; } /** @@ -353,6 +387,12 @@ export interface UserSettingsStore { readonly syncing: boolean; /** Whether settings are loaded */ readonly loaded: boolean; + /** Current device ID */ + readonly deviceId: string; + /** All device settings */ + readonly deviceSettings: Record; + /** Current device's app settings */ + readonly currentDeviceAppSettings: Record; /** Load settings from server */ load: () => Promise; @@ -372,6 +412,14 @@ export interface UserSettingsStore { toggleNavItemVisibility: (appId: string, href: string) => Promise; /** Set hidden nav items for an app */ setHiddenNavItems: (appId: string, hiddenHrefs: string[]) => Promise; + /** Update device-specific app settings */ + updateDeviceAppSettings: (settings: Record) => Promise; + /** Get device-specific app settings */ + getDeviceAppSettings: () => Record; + /** List all devices */ + getDevices: () => Promise; + /** Remove a device */ + removeDevice: (deviceId: string) => Promise; } /** @@ -384,261 +432,8 @@ export interface UserSettingsStoreConfig { authUrl: string; /** Function to get current access token */ getAccessToken: () => Promise; + /** Optional device name (auto-detected if not provided) */ + deviceName?: string; + /** Optional device type (auto-detected if not provided) */ + deviceType?: DeviceType; } - -// ============================================================================ -// Custom & Community Themes Types -// ============================================================================ - -/** - * Partial theme colors for API DTOs (some fields optional) - */ -export interface ThemeColorsInput { - primary: HSLValue; - primaryForeground?: HSLValue; - background: HSLValue; - foreground: HSLValue; - surface: HSLValue; - surfaceHover?: HSLValue; - surfaceElevated?: HSLValue; - muted?: HSLValue; - mutedForeground?: HSLValue; - border?: HSLValue; - borderStrong?: HSLValue; - secondary?: HSLValue; - secondaryForeground?: HSLValue; - input?: HSLValue; - ring?: HSLValue; - error: HSLValue; - success: HSLValue; - warning: HSLValue; -} - -/** - * User-created custom theme - */ -export interface CustomTheme { - id: string; - userId: string; - name: string; - description?: string; - emoji: string; - icon: string; - lightColors: ThemeColors; - darkColors: ThemeColors; - baseVariant?: ThemeVariant; - isPublished: boolean; - createdAt: Date; - updatedAt: Date; -} - -/** - * Input for creating a new custom theme - */ -export interface CreateCustomThemeInput { - name: string; - description?: string; - emoji?: string; - icon?: string; - lightColors: ThemeColorsInput; - darkColors: ThemeColorsInput; - baseVariant?: ThemeVariant; -} - -/** - * Input for updating a custom theme - */ -export interface UpdateCustomThemeInput { - name?: string; - description?: string; - emoji?: string; - icon?: string; - lightColors?: ThemeColorsInput; - darkColors?: ThemeColorsInput; - baseVariant?: ThemeVariant; -} - -/** - * Community theme shared publicly - */ -export interface CommunityTheme { - id: string; - authorId?: string; - authorName?: string; - name: string; - description?: string; - emoji: string; - icon: string; - lightColors: ThemeColors; - darkColors: ThemeColors; - baseVariant?: ThemeVariant; - downloadCount: number; - averageRating: number; - ratingCount: number; - status: 'pending' | 'approved' | 'rejected' | 'featured'; - isFeatured: boolean; - tags: string[]; - createdAt: Date; - publishedAt?: Date; - /** User-specific fields (when authenticated) */ - isFavorited?: boolean; - isDownloaded?: boolean; - userRating?: number; -} - -/** - * Query parameters for browsing community themes - */ -export interface CommunityThemeQuery { - page?: number; - limit?: number; - sort?: 'popular' | 'recent' | 'rating' | 'downloads'; - search?: string; - tags?: string[]; - authorId?: string; - featuredOnly?: boolean; -} - -/** - * Paginated response for community themes - */ -export interface PaginatedCommunityThemes { - themes: CommunityTheme[]; - total: number; - page: number; - limit: number; - totalPages: number; -} - -/** - * Input for publishing a theme to the community - */ -export interface PublishThemeInput { - tags?: string[]; - description?: string; -} - -/** - * Theme editor state for UI - */ -export interface ThemeEditorState { - /** Theme being edited */ - theme: Partial; - /** Currently editing light or dark colors */ - editingMode: EffectiveMode; - /** Currently selected color key */ - selectedColorKey: keyof ThemeColors | null; - /** Is preview mode active */ - isPreviewing: boolean; - /** Has unsaved changes */ - isDirty: boolean; -} - -/** - * Custom themes store interface - */ -export interface CustomThemesStore { - /** User's custom themes */ - readonly customThemes: CustomTheme[]; - /** Community themes (from current query) */ - readonly communityThemes: CommunityTheme[]; - /** User's favorited themes */ - readonly favorites: CommunityTheme[]; - /** User's downloaded themes */ - readonly downloaded: CommunityTheme[]; - /** Pagination info */ - readonly pagination: { page: number; totalPages: number; total: number }; - /** Loading state */ - readonly loading: boolean; - /** Error state */ - readonly error: string | null; - - // Custom theme operations - loadCustomThemes: () => Promise; - createTheme: (input: CreateCustomThemeInput) => Promise; - updateTheme: (id: string, input: UpdateCustomThemeInput) => Promise; - deleteTheme: (id: string) => Promise; - publishTheme: (id: string, input?: PublishThemeInput) => Promise; - - // Community theme operations - browseCommunity: (query?: CommunityThemeQuery) => Promise; - downloadTheme: (id: string) => Promise; - rateTheme: ( - id: string, - rating: number - ) => Promise<{ averageRating: number; ratingCount: number }>; - toggleFavorite: (id: string) => Promise<{ isFavorited: boolean }>; - loadFavorites: () => Promise; - loadDownloaded: () => Promise; - - // Apply theme - applyCustomTheme: (theme: CustomTheme | CommunityTheme) => void; - clearCustomTheme: () => void; -} - -/** - * Custom themes store configuration - */ -export interface CustomThemesStoreConfig { - /** Auth service base URL */ - authUrl: string; - /** Function to get current access token */ - getAccessToken: () => Promise; - /** Theme store to apply custom themes to */ - themeStore?: ThemeStore; -} - -/** - * Main colors for the simplified editor view - * These are the 7 most important colors users typically want to customize - */ -export const MAIN_THEME_COLORS: (keyof ThemeColors)[] = [ - 'primary', - 'background', - 'surface', - 'foreground', - 'error', - 'success', - 'warning', -]; - -/** - * Extended/advanced colors (collapsed by default in editor) - */ -export const EXTENDED_THEME_COLORS: (keyof ThemeColors)[] = [ - 'primaryForeground', - 'secondary', - 'secondaryForeground', - 'surfaceHover', - 'surfaceElevated', - 'muted', - 'mutedForeground', - 'border', - 'borderStrong', - 'input', - 'ring', -]; - -/** - * Color labels for the editor UI - */ -export const THEME_COLOR_LABELS: Record = { - primary: 'Primary', - primaryForeground: 'Primary Text', - secondary: 'Secondary', - secondaryForeground: 'Secondary Text', - background: 'Background', - foreground: 'Text', - surface: 'Surface', - surfaceHover: 'Surface Hover', - surfaceElevated: 'Elevated Surface', - muted: 'Muted', - mutedForeground: 'Muted Text', - border: 'Border', - borderStrong: 'Border Strong', - error: 'Error', - success: 'Success', - warning: 'Warning', - input: 'Input', - ring: 'Focus Ring', -}; diff --git a/packages/shared-theme/src/user-settings-store.svelte.ts b/packages/shared-theme/src/user-settings-store.svelte.ts index c2ecabd16..eef8de7ea 100644 --- a/packages/shared-theme/src/user-settings-store.svelte.ts +++ b/packages/shared-theme/src/user-settings-store.svelte.ts @@ -7,12 +7,74 @@ import type { ThemeSettings, UserSettingsResponse, GeneralSettings, + DeviceAppSettings, + DeviceInfo, + DeviceType, + DevicesListResponse, } from './types'; import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types'; import { isBrowser } from './utils'; import { getStartPage as getStartPageFromConfig } from './app-routes'; const STORAGE_KEY_PREFIX = 'manacore-user-settings'; +const DEVICE_ID_KEY = 'manacore-device-id'; + +/** + * Generate a unique device ID + */ +function generateDeviceId(): string { + return 'dev_' + crypto.randomUUID().replace(/-/g, '').substring(0, 16); +} + +/** + * Get or create device ID from localStorage + */ +function getOrCreateDeviceId(): string { + if (!isBrowser()) return 'server'; + try { + let deviceId = localStorage.getItem(DEVICE_ID_KEY); + if (!deviceId) { + deviceId = generateDeviceId(); + localStorage.setItem(DEVICE_ID_KEY, deviceId); + } + return deviceId; + } catch { + return generateDeviceId(); + } +} + +/** + * Detect device type based on user agent and screen size + */ +function detectDeviceType(): DeviceType { + if (!isBrowser()) return 'desktop'; + const ua = navigator.userAgent.toLowerCase(); + const isMobile = /mobile|iphone|ipod|android.*mobile|windows phone/i.test(ua); + const isTablet = /tablet|ipad|android(?!.*mobile)/i.test(ua); + if (isTablet) return 'tablet'; + if (isMobile) return 'mobile'; + return 'desktop'; +} + +/** + * Detect device name based on user agent + */ +function detectDeviceName(): string { + if (!isBrowser()) return 'Server'; + const ua = navigator.userAgent; + // Try to extract device/browser info + if (/iPhone/.test(ua)) return 'iPhone'; + if (/iPad/.test(ua)) return 'iPad'; + if (/Android/.test(ua)) { + const match = ua.match(/Android.*;\s*([^;)]+)/); + if (match) return match[1].trim(); + return 'Android Gerät'; + } + if (/Mac/.test(ua)) return 'Mac'; + if (/Windows/.test(ua)) return 'Windows PC'; + if (/Linux/.test(ua)) return 'Linux PC'; + return 'Unbekanntes Gerät'; +} /** * Create a User Settings store for your app @@ -41,12 +103,18 @@ const STORAGE_KEY_PREFIX = 'manacore-user-settings'; * ``` */ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSettingsStore { - const { appId, authUrl, getAccessToken } = config; + const { appId, authUrl, getAccessToken, deviceName, deviceType } = config; const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`; + // Device info (initialized once) + const deviceId = getOrCreateDeviceId(); + const detectedDeviceType = deviceType || detectDeviceType(); + const detectedDeviceName = deviceName || detectDeviceName(); + // State let globalSettings = $state({ ...DEFAULT_GLOBAL_SETTINGS }); let appOverrides = $state>({}); + let deviceSettings = $state>({}); let syncing = $state(false); let loaded = $state(false); @@ -88,6 +156,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe JSON.stringify({ globalSettings, appOverrides, + deviceSettings, timestamp: Date.now(), }) ); @@ -111,6 +180,9 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data.appOverrides) { appOverrides = data.appOverrides; } + if (data.deviceSettings) { + deviceSettings = data.deviceSettings; + } return true; } } catch (e) { @@ -165,6 +237,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; saveToStorage(); loaded = true; } @@ -205,6 +278,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure @@ -242,6 +316,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure @@ -303,6 +378,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure @@ -354,6 +430,108 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe } as Partial); } + // ============================================================================ + // Device Settings Functions + // ============================================================================ + + /** + * Update device-specific app settings for current device + */ + async function updateDeviceAppSettings(settings: Record): Promise { + // Optimistic update + const previousDeviceSettings = { ...deviceSettings }; + const existingDevice = deviceSettings[deviceId] || { + deviceName: detectedDeviceName, + deviceType: detectedDeviceType, + lastSeen: new Date().toISOString(), + apps: {}, + }; + + deviceSettings = { + ...deviceSettings, + [deviceId]: { + ...existingDevice, + lastSeen: new Date().toISOString(), + apps: { + ...existingDevice.apps, + [appId]: { + ...(existingDevice.apps?.[appId] || {}), + ...settings, + }, + }, + }, + }; + saveToStorage(); + + syncing = true; + try { + const data = await apiRequest( + 'PATCH', + `/device/${deviceId}/${appId}`, + { + deviceName: detectedDeviceName, + deviceType: detectedDeviceType, + settings, + } + ); + + if (data?.success) { + globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; + appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; + saveToStorage(); + } else { + // Rollback on failure + deviceSettings = previousDeviceSettings; + saveToStorage(); + } + } finally { + syncing = false; + } + } + + /** + * Get device-specific app settings for current device + */ + function getDeviceAppSettings(): Record { + const device = deviceSettings[deviceId]; + if (!device?.apps?.[appId]) return {}; + return device.apps[appId]; + } + + /** + * Get list of all devices + */ + async function getDevices(): Promise { + const data = await apiRequest('GET', '/devices'); + if (data?.success) { + return data.devices; + } + return []; + } + + /** + * Remove a device + */ + async function removeDevice(targetDeviceId: string): Promise { + syncing = true; + try { + const data = await apiRequest( + 'DELETE', + `/device/${targetDeviceId}` + ); + + if (data?.success) { + globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; + appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; + saveToStorage(); + } + } finally { + syncing = false; + } + } + return { get nav() { return nav; @@ -382,6 +560,17 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe get loaded() { return loaded; }, + get deviceId() { + return deviceId; + }, + get deviceSettings() { + return deviceSettings; + }, + get currentDeviceAppSettings() { + const device = deviceSettings[deviceId]; + if (!device?.apps?.[appId]) return {}; + return device.apps[appId]; + }, load, updateGlobal, @@ -392,5 +581,9 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe getHiddenNavItemsForApp, toggleNavItemVisibility, setHiddenNavItems, + updateDeviceAppSettings, + getDeviceAppSettings, + getDevices, + removeDevice, }; } diff --git a/services/mana-core-auth/src/db/schema/auth.schema.ts b/services/mana-core-auth/src/db/schema/auth.schema.ts index d8339a9d9..47eecd4cb 100644 --- a/services/mana-core-auth/src/db/schema/auth.schema.ts +++ b/services/mana-core-auth/src/db/schema/auth.schema.ts @@ -141,10 +141,14 @@ export const userSettings = authSchema.table('user_settings', { }) .notNull(), - // Per-app overrides + // Per-app overrides (applies to all devices) // { "calendar": { nav: {...}, theme: {...} }, "chat": {...} } appOverrides: jsonb('app_overrides').default({}).notNull(), + // Per-device settings (device-specific app settings) + // { "device-abc-123": { deviceName: "MacBook", deviceType: "desktop", lastSeen: "...", apps: { "calendar": { dayStartHour: 6, ... } } } } + deviceSettings: jsonb('device_settings').default({}).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); diff --git a/services/mana-core-auth/src/settings/dto/index.ts b/services/mana-core-auth/src/settings/dto/index.ts index 2f315c1fe..33657dcf3 100644 --- a/services/mana-core-auth/src/settings/dto/index.ts +++ b/services/mana-core-auth/src/settings/dto/index.ts @@ -70,6 +70,34 @@ export class UpdateAppOverrideDto { theme?: ThemeSettingsDto; } +// Device settings update +export class UpdateDeviceAppSettingsDto { + @IsOptional() + @IsString() + deviceName?: string; + + @IsOptional() + @IsIn(['desktop', 'mobile', 'tablet']) + deviceType?: 'desktop' | 'mobile' | 'tablet'; + + @IsObject() + settings: Record; +} + +// Register/update device info +export class RegisterDeviceDto { + @IsString() + deviceId: string; + + @IsOptional() + @IsString() + deviceName?: string; + + @IsOptional() + @IsIn(['desktop', 'mobile', 'tablet']) + deviceType?: 'desktop' | 'mobile' | 'tablet'; +} + // Response types (for documentation) export interface NavSettings { desktopPosition: 'top' | 'bottom'; @@ -94,7 +122,29 @@ export interface AppOverride { theme?: Partial; } +// Device-specific app settings +export interface DeviceAppSettings { + deviceName: string; + deviceType: 'desktop' | 'mobile' | 'tablet'; + lastSeen: string; + apps: Record>; +} + +// Device info for listing +export interface DeviceInfo { + deviceId: string; + deviceName: string; + deviceType: 'desktop' | 'mobile' | 'tablet'; + lastSeen: string; + appCount: number; +} + export interface UserSettingsResponse { globalSettings: GlobalSettings; appOverrides: Record; + deviceSettings: Record; +} + +export interface DevicesListResponse { + devices: DeviceInfo[]; } diff --git a/services/mana-core-auth/src/settings/settings.controller.ts b/services/mana-core-auth/src/settings/settings.controller.ts index 6e678395d..4516b3bce 100644 --- a/services/mana-core-auth/src/settings/settings.controller.ts +++ b/services/mana-core-auth/src/settings/settings.controller.ts @@ -3,7 +3,7 @@ import { SettingsService } from './settings.service'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import type { CurrentUserData } from '../common/decorators/current-user.decorator'; -import { UpdateGlobalSettingsDto } from './dto'; +import { UpdateGlobalSettingsDto, UpdateDeviceAppSettingsDto } from './dto'; import type { UpdateAppOverrideDto } from './dto'; @Controller('settings') @@ -13,7 +13,7 @@ export class SettingsController { /** * GET /api/v1/settings - * Get all user settings (global + app overrides) + * Get all user settings (global + app overrides + device settings) */ @Get() async getSettings(@CurrentUser() user: CurrentUserData) { @@ -69,4 +69,95 @@ export class SettingsController { ...settings, }; } + + // ============================================================================ + // Device Settings Endpoints + // ============================================================================ + + /** + * GET /api/v1/settings/devices + * List all devices for the current user + */ + @Get('devices') + async getDevices(@CurrentUser() user: CurrentUserData) { + const result = await this.settingsService.getDevices(user.userId); + return { + success: true, + ...result, + }; + } + + /** + * GET /api/v1/settings/device/:deviceId/:appId + * Get settings for a specific device and app + */ + @Get('device/:deviceId/:appId') + async getDeviceAppSettings( + @CurrentUser() user: CurrentUserData, + @Param('deviceId') deviceId: string, + @Param('appId') appId: string + ) { + const settings = await this.settingsService.getDeviceAppSettings(user.userId, deviceId, appId); + return { + success: true, + settings, + }; + } + + /** + * PATCH /api/v1/settings/device/:deviceId/:appId + * Update settings for a specific device and app + */ + @Patch('device/:deviceId/:appId') + async updateDeviceAppSettings( + @CurrentUser() user: CurrentUserData, + @Param('deviceId') deviceId: string, + @Param('appId') appId: string, + @Body() dto: UpdateDeviceAppSettingsDto + ) { + const settings = await this.settingsService.updateDeviceAppSettings( + user.userId, + deviceId, + appId, + dto + ); + return { + success: true, + ...settings, + }; + } + + /** + * DELETE /api/v1/settings/device/:deviceId + * Remove a device entirely + */ + @Delete('device/:deviceId') + async removeDevice(@CurrentUser() user: CurrentUserData, @Param('deviceId') deviceId: string) { + const settings = await this.settingsService.removeDevice(user.userId, deviceId); + return { + success: true, + ...settings, + }; + } + + /** + * DELETE /api/v1/settings/device/:deviceId/:appId + * Remove app settings from a specific device + */ + @Delete('device/:deviceId/:appId') + async removeDeviceAppSettings( + @CurrentUser() user: CurrentUserData, + @Param('deviceId') deviceId: string, + @Param('appId') appId: string + ) { + const settings = await this.settingsService.removeDeviceAppSettings( + user.userId, + deviceId, + appId + ); + return { + success: true, + ...settings, + }; + } } diff --git a/services/mana-core-auth/src/settings/settings.service.ts b/services/mana-core-auth/src/settings/settings.service.ts index 20051f4d7..02594ed36 100644 --- a/services/mana-core-auth/src/settings/settings.service.ts +++ b/services/mana-core-auth/src/settings/settings.service.ts @@ -6,9 +6,13 @@ import { userSettings } from '../db/schema'; import { type UpdateGlobalSettingsDto, type UpdateAppOverrideDto, + type UpdateDeviceAppSettingsDto, type GlobalSettings, type AppOverride, + type DeviceAppSettings, + type DeviceInfo, type UserSettingsResponse, + type DevicesListResponse, } from './dto'; // Default settings for new users @@ -46,6 +50,7 @@ export class SettingsService { return { globalSettings: existing.globalSettings as GlobalSettings, appOverrides: existing.appOverrides as Record, + deviceSettings: (existing.deviceSettings as Record) || {}, }; } @@ -56,6 +61,7 @@ export class SettingsService { userId, globalSettings: DEFAULT_GLOBAL_SETTINGS, appOverrides: {}, + deviceSettings: {}, }) .returning(); @@ -64,6 +70,7 @@ export class SettingsService { return { globalSettings: created.globalSettings as GlobalSettings, appOverrides: created.appOverrides as Record, + deviceSettings: (created.deviceSettings as Record) || {}, }; } @@ -101,6 +108,7 @@ export class SettingsService { return { globalSettings: updated.globalSettings as GlobalSettings, appOverrides: updated.appOverrides as Record, + deviceSettings: (updated.deviceSettings as Record) || {}, }; } @@ -155,6 +163,7 @@ export class SettingsService { return { globalSettings: updated.globalSettings as GlobalSettings, appOverrides: updated.appOverrides as Record, + deviceSettings: (updated.deviceSettings as Record) || {}, }; } @@ -186,6 +195,185 @@ export class SettingsService { return { globalSettings: updated.globalSettings as GlobalSettings, appOverrides: updated.appOverrides as Record, + deviceSettings: (updated.deviceSettings as Record) || {}, + }; + } + + // ============================================================================ + // Device Settings Methods + // ============================================================================ + + /** + * Get list of all devices for a user + */ + async getDevices(userId: string): Promise { + const current = await this.getSettings(userId); + const deviceSettings = current.deviceSettings || {}; + + const devices: DeviceInfo[] = Object.entries(deviceSettings).map(([deviceId, device]) => ({ + deviceId, + deviceName: device.deviceName || 'Unbekanntes Gerät', + deviceType: device.deviceType || 'desktop', + lastSeen: device.lastSeen || new Date().toISOString(), + appCount: Object.keys(device.apps || {}).length, + })); + + // Sort by lastSeen descending + devices.sort((a, b) => new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime()); + + return { devices }; + } + + /** + * Get settings for a specific device and app + */ + async getDeviceAppSettings( + userId: string, + deviceId: string, + appId: string + ): Promise> { + const current = await this.getSettings(userId); + const deviceSettings = current.deviceSettings || {}; + const device = deviceSettings[deviceId]; + + if (!device || !device.apps || !device.apps[appId]) { + return {}; + } + + return device.apps[appId]; + } + + /** + * Update settings for a specific device and app + */ + async updateDeviceAppSettings( + userId: string, + deviceId: string, + appId: string, + dto: UpdateDeviceAppSettingsDto + ): Promise { + const db = this.getDb(); + + // Get current settings + const current = await this.getSettings(userId); + const deviceSettings = { ...(current.deviceSettings || {}) }; + + // Get or create device entry + const existingDevice = deviceSettings[deviceId] || { + deviceName: dto.deviceName || 'Unbekanntes Gerät', + deviceType: dto.deviceType || 'desktop', + lastSeen: new Date().toISOString(), + apps: {}, + }; + + // Update device info if provided + const updatedDevice: DeviceAppSettings = { + deviceName: dto.deviceName || existingDevice.deviceName, + deviceType: dto.deviceType || existingDevice.deviceType, + lastSeen: new Date().toISOString(), + apps: { + ...existingDevice.apps, + [appId]: { + ...(existingDevice.apps?.[appId] || {}), + ...dto.settings, + }, + }, + }; + + deviceSettings[deviceId] = updatedDevice; + + // Update in database + const [updated] = await db + .update(userSettings) + .set({ + deviceSettings, + updatedAt: new Date(), + }) + .where(eq(userSettings.userId, userId)) + .returning(); + + this.logger.debug( + `Updated device settings for user ${userId}, device ${deviceId}, app ${appId}` + ); + + return { + globalSettings: updated.globalSettings as GlobalSettings, + appOverrides: updated.appOverrides as Record, + deviceSettings: (updated.deviceSettings as Record) || {}, + }; + } + + /** + * Remove a device entirely + */ + async removeDevice(userId: string, deviceId: string): Promise { + const db = this.getDb(); + + // Get current settings + const current = await this.getSettings(userId); + const deviceSettings = { ...(current.deviceSettings || {}) }; + + // Remove the device + delete deviceSettings[deviceId]; + + // Update in database + const [updated] = await db + .update(userSettings) + .set({ + deviceSettings, + updatedAt: new Date(), + }) + .where(eq(userSettings.userId, userId)) + .returning(); + + this.logger.debug(`Removed device ${deviceId} for user ${userId}`); + + return { + globalSettings: updated.globalSettings as GlobalSettings, + appOverrides: updated.appOverrides as Record, + deviceSettings: (updated.deviceSettings as Record) || {}, + }; + } + + /** + * Remove app settings from a specific device + */ + async removeDeviceAppSettings( + userId: string, + deviceId: string, + appId: string + ): Promise { + const db = this.getDb(); + + // Get current settings + const current = await this.getSettings(userId); + const deviceSettings = { ...(current.deviceSettings || {}) }; + + if (deviceSettings[deviceId]?.apps) { + const device = { ...deviceSettings[deviceId] }; + const apps = { ...device.apps }; + delete apps[appId]; + device.apps = apps; + device.lastSeen = new Date().toISOString(); + deviceSettings[deviceId] = device; + } + + // Update in database + const [updated] = await db + .update(userSettings) + .set({ + deviceSettings, + updatedAt: new Date(), + }) + .where(eq(userSettings.userId, userId)) + .returning(); + + this.logger.debug(`Removed app ${appId} settings from device ${deviceId} for user ${userId}`); + + return { + globalSettings: updated.globalSettings as GlobalSettings, + appOverrides: updated.appOverrides as Record, + deviceSettings: (updated.deviceSettings as Record) || {}, }; } }