feat(settings): add device-specific settings storage

Implement per-device settings sync via mana-core-auth. Settings are now
stored both locally (localStorage) and in the cloud, with each device
(desktop, mobile, tablet) maintaining its own configuration.

Changes:
- Add deviceSettings JSONB column to user_settings table
- Add device API endpoints (GET/PATCH/DELETE /settings/device/:id/:app)
- Extend user-settings-store with device ID generation and detection
- Integrate calendar settings with cloud sync per device
- Remove todos from calendar header row (sidebar + grid only)
- Add hours dropdown to CalendarHeader for time range configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-11 23:49:18 +01:00
parent 5921cfd257
commit c6f8b9f87c
11 changed files with 863 additions and 416 deletions

View file

@ -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<CalendarViewType, string> = {
@ -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);
}
</script>
<header class="calendar-header" class:nav-collapsed={$isNavCollapsed}>
<div class="header-left">
<button class="today-btn" onclick={() => viewStore.goToToday()}> Heute </button>
<button
class="pill glass-pill today-btn"
onclick={() => viewStore.goToToday()}
title="Zum heutigen Tag springen"
>
Heute
</button>
<div class="nav-buttons">
<div class="nav-buttons glass-pill">
<button class="nav-btn" onclick={() => viewStore.goToPrevious()} aria-label="Zurück">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -90,8 +113,9 @@
/>
</svg>
</button>
<div class="nav-divider"></div>
<button class="nav-btn" onclick={() => viewStore.goToNext()} aria-label="Weiter">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
@ -101,11 +125,11 @@
</div>
<div class="header-right">
<!-- Filter toggles -->
<div class="filter-toggles">
<!-- Filter toggles as pills -->
<div class="filter-pills">
<!-- Weekdays only toggle -->
<button
class="filter-toggle"
class="pill glass-pill filter-pill"
class:active={settingsStore.showOnlyWeekdays}
onclick={() => settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)}
title="Nur Wochentage anzeigen (Mo-Fr)"
@ -113,28 +137,40 @@
Mo-Fr
</button>
<!-- Filter hours toggle -->
<!-- Hours filter toggle -->
<button
class="filter-toggle"
class="pill glass-pill filter-pill"
class:active={settingsStore.filterHoursEnabled}
onclick={() => settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)}
title="Stunden filtern ({settingsStore.dayStartHour}-{settingsStore.dayEndHour} Uhr)"
title="Stundenfilter ein/aus"
>
{settingsStore.dayStartHour}-{settingsStore.dayEndHour}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Hours time range selector -->
<PillTimeRangeSelector
startHour={settingsStore.dayStartHour}
endHour={settingsStore.dayEndHour}
onStartHourChange={handleStartHourChange}
onEndHourChange={handleEndHourChange}
direction="down"
/>
</div>
<div class="view-selector">
{#each visibleViews as type}
<button
class="view-btn"
class:active={viewStore.viewType === type}
onclick={() => handleViewChange(type)}
>
{viewLabels[type]}
</button>
{/each}
</div>
<!-- View selector -->
<PillViewSwitcher
options={viewOptions}
value={viewStore.viewType}
onChange={handleViewChange}
primaryColor="#3b82f6"
/>
</div>
</header>
@ -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;
}
}
</style>

View file

@ -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 @@
</div>
{/if}
<!-- Todos section -->
{#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0}
<div class="todos-section">
<div class="time-gutter"></div>
<div class="todos-content">
<TodoRow date={viewStore.currentDate} maxVisible={4} />
</div>
</div>
{/if}
<!-- Time grid -->
<div class="time-grid scrollbar-thin">
<div class="time-column">
@ -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;

View file

@ -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 @@
</div>
{/if}
<!-- Todos row (shown per day, below all-day events) -->
{#if todosStore.serviceAvailable}
<div class="todos-row">
<div class="time-gutter"></div>
{#each days as day}
<div class="todos-cell">
<TodoRow date={day} maxVisible={2} />
</div>
{/each}
</div>
{/if}
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
@ -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;

View file

@ -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<CalendarAppSettings>(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<string, unknown>);
} catch (e) {
console.error('Failed to sync calendar settings to cloud:', e);
}
}
/**
* Load settings from cloud (device-specific)
*/
function loadFromCloud(): Partial<CalendarAppSettings> | null {
if (!userSettings.loaded) return null;
const cloudSettings = userSettings.currentDeviceAppSettings;
if (cloudSettings && Object.keys(cloudSettings).length > 0) {
return cloudSettings as unknown as Partial<CalendarAppSettings>;
}
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<K extends keyof CalendarAppSettings>(key: K, value: CalendarAppSettings[K]) {
settings = { ...settings, [key]: value };
saveSettings(settings);
syncToCloud();
},
/**
@ -151,6 +214,7 @@ export const settingsStore = {
update(updates: Partial<CalendarAppSettings>) {
settings = { ...settings, ...updates };
saveSettings(settings);
syncToCloud();
},
/**
@ -159,6 +223,7 @@ export const settingsStore = {
reset() {
settings = { ...DEFAULT_SETTINGS };
saveSettings(settings);
syncToCloud();
},
/**

View file

@ -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();
}
});
</script>
<ToastContainer />

View file

@ -302,12 +302,46 @@ export interface AppOverride {
theme?: Partial<ThemeSettings>;
}
/**
* 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<string, Record<string, unknown>>;
}
/**
* 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<string, AppOverride>;
deviceSettings: Record<string, DeviceAppSettings>;
}
/**
* 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<string, DeviceAppSettings>;
/** Current device's app settings */
readonly currentDeviceAppSettings: Record<string, unknown>;
/** Load settings from server */
load: () => Promise<void>;
@ -372,6 +412,14 @@ export interface UserSettingsStore {
toggleNavItemVisibility: (appId: string, href: string) => Promise<void>;
/** Set hidden nav items for an app */
setHiddenNavItems: (appId: string, hiddenHrefs: string[]) => Promise<void>;
/** Update device-specific app settings */
updateDeviceAppSettings: (settings: Record<string, unknown>) => Promise<void>;
/** Get device-specific app settings */
getDeviceAppSettings: () => Record<string, unknown>;
/** List all devices */
getDevices: () => Promise<DeviceInfo[]>;
/** Remove a device */
removeDevice: (deviceId: string) => Promise<void>;
}
/**
@ -384,261 +432,8 @@ export interface UserSettingsStoreConfig {
authUrl: string;
/** Function to get current access token */
getAccessToken: () => Promise<string | null>;
/** 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<CreateCustomThemeInput>;
/** 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<void>;
createTheme: (input: CreateCustomThemeInput) => Promise<CustomTheme>;
updateTheme: (id: string, input: UpdateCustomThemeInput) => Promise<CustomTheme>;
deleteTheme: (id: string) => Promise<void>;
publishTheme: (id: string, input?: PublishThemeInput) => Promise<CommunityTheme>;
// Community theme operations
browseCommunity: (query?: CommunityThemeQuery) => Promise<void>;
downloadTheme: (id: string) => Promise<CommunityTheme>;
rateTheme: (
id: string,
rating: number
) => Promise<{ averageRating: number; ratingCount: number }>;
toggleFavorite: (id: string) => Promise<{ isFavorited: boolean }>;
loadFavorites: () => Promise<void>;
loadDownloaded: () => Promise<void>;
// 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<string | null>;
/** 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<keyof ThemeColors, string> = {
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',
};

View file

@ -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<GlobalSettings>({ ...DEFAULT_GLOBAL_SETTINGS });
let appOverrides = $state<Record<string, AppOverride>>({});
let deviceSettings = $state<Record<string, DeviceAppSettings>>({});
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<GlobalSettings>);
}
// ============================================================================
// Device Settings Functions
// ============================================================================
/**
* Update device-specific app settings for current device
*/
async function updateDeviceAppSettings(settings: Record<string, unknown>): Promise<void> {
// 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<UserSettingsResponse & { success: boolean }>(
'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<string, unknown> {
const device = deviceSettings[deviceId];
if (!device?.apps?.[appId]) return {};
return device.apps[appId];
}
/**
* Get list of all devices
*/
async function getDevices(): Promise<DeviceInfo[]> {
const data = await apiRequest<DevicesListResponse & { success: boolean }>('GET', '/devices');
if (data?.success) {
return data.devices;
}
return [];
}
/**
* Remove a device
*/
async function removeDevice(targetDeviceId: string): Promise<void> {
syncing = true;
try {
const data = await apiRequest<UserSettingsResponse & { success: boolean }>(
'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,
};
}

View file

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

View file

@ -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<string, unknown>;
}
// 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<ThemeSettings>;
}
// Device-specific app settings
export interface DeviceAppSettings {
deviceName: string;
deviceType: 'desktop' | 'mobile' | 'tablet';
lastSeen: string;
apps: Record<string, Record<string, unknown>>;
}
// 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<string, AppOverride>;
deviceSettings: Record<string, DeviceAppSettings>;
}
export interface DevicesListResponse {
devices: DeviceInfo[];
}

View file

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

View file

@ -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<string, AppOverride>,
deviceSettings: (existing.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
@ -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<string, AppOverride>,
deviceSettings: (created.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
@ -101,6 +108,7 @@ export class SettingsService {
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
@ -155,6 +163,7 @@ export class SettingsService {
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
@ -186,6 +195,185 @@ export class SettingsService {
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
// ============================================================================
// Device Settings Methods
// ============================================================================
/**
* Get list of all devices for a user
*/
async getDevices(userId: string): Promise<DevicesListResponse> {
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<Record<string, unknown>> {
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<UserSettingsResponse> {
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<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Remove a device entirely
*/
async removeDevice(userId: string, deviceId: string): Promise<UserSettingsResponse> {
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<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Remove app settings from a specific device
*/
async removeDeviceAppSettings(
userId: string,
deviceId: string,
appId: string
): Promise<UserSettingsResponse> {
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<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
}