mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
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:
parent
5921cfd257
commit
c6f8b9f87c
11 changed files with 863 additions and 416 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) || {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue