mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(manacore): migrate settings, dashboard, and tags to local-first
Move ManaCore from server-only data fetching to local-first architecture using @manacore/local-store (IndexedDB + mana-sync). Dashboard config now syncs across devices instead of being localStorage-only, and tags use the shared local-first tag store consistent with all other apps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d390be5af
commit
62e1353503
10 changed files with 256 additions and 75 deletions
|
|
@ -558,7 +558,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg
|
|||
← WebSocket push ←
|
||||
```
|
||||
|
||||
### Migrated Apps (20/23)
|
||||
### Migrated Apps (21/23)
|
||||
|
||||
| App | Collections | Status |
|
||||
|-----|------------|--------|
|
||||
|
|
@ -584,8 +584,9 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg
|
|||
| Taktik | clients, projects, timeEntries, tags, templates, settings | Done |
|
||||
| uLoad | links, tags, folders, linkTags | Done |
|
||||
| Calc | calculations, savedFormulas | Done |
|
||||
| ManaCore | userSettings, dashboardConfigs | Done |
|
||||
|
||||
**Not migrated (no CRUD data model):** ManaCore (hub), Matrix (protocol client), Playground (stateless)
|
||||
**Not migrated (no CRUD data model):** Matrix (protocol client), Playground (stateless)
|
||||
|
||||
### Dev Commands (Local-First Stack)
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/credits": "workspace:^",
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/qr-export": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-stores": "workspace:*",
|
||||
|
|
|
|||
58
apps/manacore/apps/web/src/lib/data/guest-seed.ts
Normal file
58
apps/manacore/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Guest seed data for ManaCore.
|
||||
*
|
||||
* These records are loaded into IndexedDB when a new guest visits the app.
|
||||
* They provide sensible defaults for settings and dashboard layout.
|
||||
*/
|
||||
|
||||
import type { LocalUserSettings, LocalDashboardConfig } from './local-store';
|
||||
|
||||
// ─── Default Settings ──────────────────────────────────────
|
||||
|
||||
export const guestSettings: LocalUserSettings[] = [
|
||||
{
|
||||
id: 'settings-global',
|
||||
key: 'global',
|
||||
theme: 'system',
|
||||
themeVariant: 'default',
|
||||
language: 'de',
|
||||
pinnedThemes: [],
|
||||
hiddenNavItems: {},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Default Dashboard ─────────────────────────────────────
|
||||
|
||||
export const guestDashboardConfigs: LocalDashboardConfig[] = [
|
||||
{
|
||||
id: 'dashboard-default',
|
||||
widgets: [
|
||||
{
|
||||
id: 'clock-timers-1',
|
||||
type: 'clock-timers',
|
||||
title: 'dashboard.widgets.clock.title',
|
||||
size: 'small',
|
||||
position: { x: 0, y: 0 },
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'tasks-today-1',
|
||||
type: 'tasks-today',
|
||||
title: 'dashboard.widgets.tasks_today.title',
|
||||
size: 'small',
|
||||
position: { x: 4, y: 0 },
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'calendar-events-1',
|
||||
type: 'calendar-events',
|
||||
title: 'dashboard.widgets.calendar.title',
|
||||
size: 'small',
|
||||
position: { x: 8, y: 0 },
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
gridColumns: 12,
|
||||
lastModified: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
59
apps/manacore/apps/web/src/lib/data/local-store.ts
Normal file
59
apps/manacore/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* ManaCore App — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* Collections: userSettings, dashboardConfigs
|
||||
* Tags use the shared tagLocalStore from @manacore/shared-stores.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import type { WidgetConfig } from '$lib/types/dashboard';
|
||||
import { guestSettings, guestDashboardConfigs } from './guest-seed.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface LocalUserSettings extends BaseRecord {
|
||||
/** Settings scope: 'global' for cross-app, or an appId for per-app overrides. */
|
||||
key: string;
|
||||
theme?: string;
|
||||
themeVariant?: string;
|
||||
language?: string;
|
||||
pinnedThemes?: string[];
|
||||
hiddenNavItems?: Record<string, boolean>;
|
||||
/** Catch-all for future settings fields. */
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LocalDashboardConfig extends BaseRecord {
|
||||
widgets: WidgetConfig[];
|
||||
gridColumns: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const manacoreStore = createLocalStore({
|
||||
appId: 'manacore',
|
||||
collections: [
|
||||
{
|
||||
name: 'userSettings',
|
||||
indexes: ['key'],
|
||||
guestSeed: guestSettings,
|
||||
},
|
||||
{
|
||||
name: 'dashboardConfigs',
|
||||
indexes: [],
|
||||
guestSeed: guestDashboardConfigs,
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const settingsCollection = manacoreStore.collection<LocalUserSettings>('userSettings');
|
||||
export const dashboardCollection =
|
||||
manacoreStore.collection<LocalDashboardConfig>('dashboardConfigs');
|
||||
36
apps/manacore/apps/web/src/lib/data/queries.ts
Normal file
36
apps/manacore/apps/web/src/lib/data/queries.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* ManaCore — Reactive Live Queries
|
||||
*
|
||||
* Svelte 5 reactive queries for settings and dashboard data.
|
||||
* Auto-update when IndexedDB changes (local writes, sync, other tabs).
|
||||
*/
|
||||
|
||||
import { useLiveQuery, useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import {
|
||||
settingsCollection,
|
||||
dashboardCollection,
|
||||
type LocalUserSettings,
|
||||
type LocalDashboardConfig,
|
||||
} from './local-store.js';
|
||||
|
||||
/** Global user settings. Auto-updates on any change. */
|
||||
export function useGlobalSettings() {
|
||||
return useLiveQuery<LocalUserSettings | undefined>(() =>
|
||||
settingsCollection.get('settings-global')
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard configuration. Auto-updates on any change. */
|
||||
export function useDashboardConfig() {
|
||||
return useLiveQuery<LocalDashboardConfig | undefined>(() =>
|
||||
dashboardCollection.get('dashboard-default')
|
||||
);
|
||||
}
|
||||
|
||||
/** All user settings (global + per-app overrides). */
|
||||
export function useAllSettings() {
|
||||
return useLiveQueryWithDefault(
|
||||
() => settingsCollection.getAll(undefined, { sortBy: 'key', sortDirection: 'asc' }),
|
||||
[] as LocalUserSettings[]
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,48 @@
|
|||
/**
|
||||
* Dashboard Store - Manages dashboard configuration using Svelte 5 runes
|
||||
* Dashboard Store - Manages dashboard configuration using local-first IndexedDB
|
||||
*
|
||||
* Handles widget layout, edit mode, and persistence to localStorage.
|
||||
* Reads/writes to the dashboardConfigs collection in @manacore/local-store.
|
||||
* Changes sync across devices via mana-sync.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import type { DashboardConfig, WidgetConfig, WidgetSize, WidgetType } from '$lib/types/dashboard';
|
||||
import { DEFAULT_DASHBOARD_CONFIG, DASHBOARD_STORAGE_KEY } from '$lib/config/default-dashboard';
|
||||
import { DEFAULT_DASHBOARD_CONFIG } from '$lib/config/default-dashboard';
|
||||
import { getWidgetMeta } from '$lib/types/dashboard';
|
||||
import { dashboardCollection, type LocalDashboardConfig } from '$lib/data/local-store';
|
||||
|
||||
// State
|
||||
let config = $state<DashboardConfig>(structuredClone(DEFAULT_DASHBOARD_CONFIG));
|
||||
let isEditing = $state(false);
|
||||
let initialized = $state(false);
|
||||
|
||||
/** Write current config to IndexedDB. */
|
||||
async function persist() {
|
||||
if (!browser) return;
|
||||
|
||||
config.lastModified = new Date().toISOString();
|
||||
|
||||
const record: Partial<LocalDashboardConfig> = {
|
||||
widgets: config.widgets,
|
||||
gridColumns: config.gridColumns,
|
||||
lastModified: config.lastModified,
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = await dashboardCollection.get('dashboard-default');
|
||||
if (existing) {
|
||||
await dashboardCollection.update('dashboard-default', record);
|
||||
} else {
|
||||
await dashboardCollection.insert({
|
||||
id: 'dashboard-default',
|
||||
...record,
|
||||
} as LocalDashboardConfig);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save dashboard config:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard store with Svelte 5 runes
|
||||
*/
|
||||
|
|
@ -36,19 +65,19 @@ export const dashboardStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Initialize dashboard from localStorage
|
||||
* Initialize dashboard from IndexedDB
|
||||
*/
|
||||
initialize() {
|
||||
async initialize() {
|
||||
if (!browser || initialized) return;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(DASHBOARD_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as DashboardConfig;
|
||||
// Validate structure
|
||||
if (parsed.widgets && Array.isArray(parsed.widgets)) {
|
||||
config = parsed;
|
||||
}
|
||||
const stored = await dashboardCollection.get('dashboard-default');
|
||||
if (stored && stored.widgets && Array.isArray(stored.widgets)) {
|
||||
config = {
|
||||
widgets: stored.widgets,
|
||||
gridColumns: stored.gridColumns || 12,
|
||||
lastModified: stored.lastModified || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load dashboard config:', e);
|
||||
|
|
@ -58,18 +87,9 @@ export const dashboardStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Persist current config to localStorage
|
||||
* Persist current config to IndexedDB
|
||||
*/
|
||||
persist() {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
config.lastModified = new Date().toISOString();
|
||||
localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(config));
|
||||
} catch (e) {
|
||||
console.error('Failed to save dashboard config:', e);
|
||||
}
|
||||
},
|
||||
persist,
|
||||
|
||||
/**
|
||||
* Enter edit mode
|
||||
|
|
@ -83,7 +103,7 @@ export const dashboardStore = {
|
|||
*/
|
||||
stopEditing() {
|
||||
isEditing = false;
|
||||
this.persist();
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -121,7 +141,7 @@ export const dashboardStore = {
|
|||
const widget = config.widgets.find((w) => w.id === widgetId);
|
||||
if (widget) {
|
||||
widget.size = size;
|
||||
this.persist();
|
||||
persist();
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -132,7 +152,7 @@ export const dashboardStore = {
|
|||
const widget = config.widgets.find((w) => w.id === widgetId);
|
||||
if (widget) {
|
||||
widget.visible = !widget.visible;
|
||||
this.persist();
|
||||
persist();
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -149,7 +169,7 @@ export const dashboardStore = {
|
|||
if (existing) {
|
||||
// Just make it visible
|
||||
existing.visible = true;
|
||||
this.persist();
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -171,7 +191,7 @@ export const dashboardStore = {
|
|||
};
|
||||
|
||||
config.widgets = [...config.widgets, newWidget];
|
||||
this.persist();
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -179,7 +199,7 @@ export const dashboardStore = {
|
|||
*/
|
||||
removeWidget(widgetId: string) {
|
||||
config.widgets = config.widgets.filter((w) => w.id !== widgetId);
|
||||
this.persist();
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -187,7 +207,7 @@ export const dashboardStore = {
|
|||
*/
|
||||
resetToDefault() {
|
||||
config = structuredClone(DEFAULT_DASHBOARD_CONFIG);
|
||||
this.persist();
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
/**
|
||||
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
|
||||
* Tag Store - Re-exports shared local-first tag store
|
||||
*
|
||||
* Tags use the shared IndexedDB ('manacore-tags') across all apps.
|
||||
* This module re-exports for backward compatibility with existing imports.
|
||||
*/
|
||||
import { browser } from '$app/environment';
|
||||
import { createTagStore } from '@manacore/shared-stores';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
export const tagStore = createTagStore({
|
||||
authUrl: getAuthUrl(),
|
||||
getToken: () => authStore.getValidToken(),
|
||||
});
|
||||
export {
|
||||
tagLocalStore,
|
||||
tagMutations,
|
||||
tagCollection,
|
||||
tagGroupCollection,
|
||||
useAllTags,
|
||||
useAllTagGroups,
|
||||
getTagById,
|
||||
getTagsByIds,
|
||||
getTagColor,
|
||||
getTagsByGroup,
|
||||
} from '@manacore/shared-stores';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation, TagStrip } from '@manacore/shared-ui';
|
||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
import { tagLocalStore, tagMutations, useAllTags } from '$lib/stores/tags.svelte';
|
||||
import { manacoreStore } from '$lib/data/local-store';
|
||||
import { dashboardStore } from '$lib/stores/dashboard.svelte';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
|
|
@ -84,6 +86,9 @@
|
|||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email);
|
||||
|
||||
// Tags (local-first reactive query)
|
||||
const allTags = useAllTags();
|
||||
|
||||
// TagStrip visibility
|
||||
let isTagStripVisible = $state(false);
|
||||
function handleTagStripToggle() {
|
||||
|
|
@ -162,6 +167,8 @@
|
|||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
manacoreStore.stopSync();
|
||||
tagMutations.stopSync();
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
|
@ -195,6 +202,17 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Initialize local-first databases (opens IndexedDB, seeds guest data)
|
||||
await Promise.all([manacoreStore.initialize(), tagLocalStore.initialize()]);
|
||||
|
||||
// Start syncing to server
|
||||
const getToken = () => authStore.getValidToken();
|
||||
manacoreStore.startSync(getToken);
|
||||
tagMutations.startSync(getToken);
|
||||
|
||||
// Initialize dashboard from IndexedDB
|
||||
await dashboardStore.initialize();
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED);
|
||||
if (savedCollapsed === 'true') {
|
||||
|
|
@ -202,14 +220,8 @@
|
|||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
// Load user settings from server (don't await - let it load in background)
|
||||
// Silently catch errors since settings endpoint may not exist yet
|
||||
userSettings.load().catch(() => {
|
||||
// Settings API not available - use defaults
|
||||
});
|
||||
|
||||
// Load tags
|
||||
tagStore.fetchTags();
|
||||
// Load user settings from server (still needed for shared-theme sync)
|
||||
userSettings.load().catch(() => {});
|
||||
|
||||
// Load onboarding state and show wizard if needed
|
||||
onboardingStore.load();
|
||||
|
|
@ -281,7 +293,7 @@
|
|||
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
|
||||
{#if isTagStripVisible}
|
||||
<TagStrip
|
||||
tags={tagStore.tags.map((t) => ({
|
||||
tags={(allTags.value ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color || '#3b82f6',
|
||||
|
|
@ -290,7 +302,7 @@
|
|||
onToggle={() => {}}
|
||||
onClear={() => {}}
|
||||
managementHref="/tags"
|
||||
loading={tagStore.loading}
|
||||
loading={allTags.loading}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
import { ManaCoreEvents } from '@manacore/shared-utils/analytics';
|
||||
import DashboardGrid from '$lib/components/dashboard/DashboardGrid.svelte';
|
||||
|
||||
onMount(() => {
|
||||
dashboardStore.initialize();
|
||||
onMount(async () => {
|
||||
await dashboardStore.initialize();
|
||||
});
|
||||
|
||||
function handleToggleEditing() {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { useAllTags } from '$lib/stores/tags.svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (tagStore.tags.length === 0) {
|
||||
tagStore.fetchTags();
|
||||
}
|
||||
});
|
||||
const tags = useAllTags();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -15,19 +10,19 @@
|
|||
|
||||
<div class="tags-page">
|
||||
<h1>Tags verwalten</h1>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
|
||||
</p>
|
||||
|
||||
{#if tagStore.loading}
|
||||
{#if tags.loading}
|
||||
<p>Lädt...</p>
|
||||
{:else if tagStore.tags.length === 0}
|
||||
{:else if (tags.value ?? []).length === 0}
|
||||
<p>Keine Tags vorhanden.</p>
|
||||
{:else}
|
||||
<div class="grid gap-2">
|
||||
{#each tagStore.tags as tag}
|
||||
<div class="flex items-center gap-2 p-2 rounded-lg bg-card">
|
||||
<span class="w-3 h-3 rounded-full" style="background-color: {tag.color}"></span>
|
||||
{#each tags.value ?? [] as tag}
|
||||
<div class="flex items-center gap-2 rounded-lg bg-card p-2">
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: {tag.color}"></span>
|
||||
<span>{tag.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue