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

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