diff --git a/apps/todo/apps/web/src/lib/stores/labels.svelte.ts b/apps/todo/apps/web/src/lib/stores/labels.svelte.ts index 191274d1b..594ee2377 100644 --- a/apps/todo/apps/web/src/lib/stores/labels.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/labels.svelte.ts @@ -1,119 +1,62 @@ /** - * Labels Store - Manages label state using Svelte 5 runes - * - * Uses the central Tags API from mana-core-auth. Labels and Tags are now - * unified across all Manacore apps (Todo, Calendar, Contacts). + * Labels Store - Uses shared Tag Store backed by central mana-core-auth */ -import type { Label } from '$lib/api/labels'; -import * as labelsApi from '$lib/api/labels'; -import { TodoEvents } from '@manacore/shared-utils/analytics'; +import { browser } from '$app/environment'; +import { createTagStore, type TagStore } from '@manacore/shared-stores'; +import { authStore } from '$lib/stores/auth.svelte'; +import type { Tag } from '@manacore/shared-tags'; -// State -let labels = $state([]); -let loading = $state(false); -let error = $state(null); +// Re-export Tag as Label for backward compatibility +export type Label = Tag; +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'; +} + +// Create the shared tag store +const tagStore: TagStore = createTagStore({ + authUrl: getAuthUrl(), + getToken: () => authStore.getValidToken(), +}); + +// Backward-compatible labelsStore wrapper export const labelsStore = { - // Getters get labels() { - return labels; + return tagStore.tags; }, get loading() { - return loading; + return tagStore.loading; }, get error() { - return error; + return tagStore.error; }, - /** - * Fetch all labels from API - */ async fetchLabels() { - loading = true; - error = null; - try { - labels = await labelsApi.getLabels(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to fetch labels'; - console.error('Failed to fetch labels:', e); - } finally { - loading = false; - } + return tagStore.fetchTags(); + }, + getById(id: string) { + return tagStore.getById(id); + }, + getColor(labelId: string) { + return tagStore.getColor(labelId); }, - /** - * Get label by ID - */ - getById(id: string): Label | undefined { - return labels.find((l) => l.id === id); - }, - - /** - * Get label color by ID - */ - getColor(labelId: string): string { - const label = labels.find((l) => l.id === labelId); - return label?.color || '#6b7280'; - }, - - /** - * Create a new label - */ async createLabel(data: { name: string; color?: string }) { - loading = true; - error = null; - try { - const newLabel = await labelsApi.createLabel(data); - labels = [...labels, newLabel]; - TodoEvents.labelCreated(); - return newLabel; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to create label'; - console.error('Failed to create label:', e); - throw e; - } finally { - loading = false; - } + return tagStore.createTag(data); }, - - /** - * Update an existing label - */ async updateLabel(id: string, data: { name?: string; color?: string }) { - error = null; - try { - const updatedLabel = await labelsApi.updateLabel(id, data); - labels = labels.map((l) => (l.id === id ? updatedLabel : l)); - return updatedLabel; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to update label'; - console.error('Failed to update label:', e); - throw e; - } + return tagStore.updateTag(id, data); }, - - /** - * Delete a label - */ async deleteLabel(id: string) { - error = null; - try { - await labelsApi.deleteLabel(id); - labels = labels.filter((l) => l.id !== id); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to delete label'; - console.error('Failed to delete label:', e); - throw e; - } + return tagStore.deleteTag(id); }, - - /** - * Clear all state (for logout) - */ clear() { - labels = []; - loading = false; - error = null; + tagStore.clear(); }, }; diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index d24315d47..df7febcb0 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -2,7 +2,12 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { locale } from 'svelte-i18n'; - import { PillNavigation, QuickInputBar, ImmersiveModeToggle } from '@manacore/shared-ui'; + import { + PillNavigation, + QuickInputBar, + ImmersiveModeToggle, + TagStrip, + } from '@manacore/shared-ui'; import { SplitPaneContainer, setSplitPanelContext, @@ -23,7 +28,6 @@ import { tasksStore } from '$lib/stores/tasks.svelte'; import { theme } from '$lib/stores/theme'; import TaskFilters from '$lib/components/TaskFilters.svelte'; - import TagStrip from '$lib/components/TagStrip.svelte'; import { viewStore, type SortBy } from '$lib/stores/view.svelte'; import type { TaskPriority } from '@todo/shared'; import { @@ -171,11 +175,17 @@ todoSettings.toggleFilterStrip(); } + // TagStrip visibility (toggle via Tags button in PillNav) + let isTagStripVisible = $state(true); + + function handleTagStripToggle() { + isTagStripVisible = !isTagStripVisible; + } + // View routes for the tab group (pages that navigate) const viewRoutes: Record = { liste: '/', kanban: '/kanban', - tags: '/tags', }; // Determine active view tab from current path @@ -183,13 +193,12 @@ Object.entries(viewRoutes).find(([_, path]) => $page.url.pathname === path)?.[0] || 'liste' ); - // Tab group for view switching (Liste, Kanban, Tags) - grouped in one pill + // Tab group for view switching (Liste, Kanban) - grouped in one pill let viewTabGroup = $derived({ type: 'tabs' as const, options: [ { id: 'liste', icon: 'list', label: 'Liste', title: 'Listenansicht' }, { id: 'kanban', icon: 'columns', label: 'Kanban', title: 'Kanban-Board' }, - { id: 'tags', icon: 'tag', label: 'Tags', title: 'Tags verwalten' }, ], value: activeViewTab, onChange: (id: string) => { @@ -198,7 +207,7 @@ }, }); - // Filter stays as a standalone pill (toggle behavior, not navigation) + // Filter and Tags stay as standalone pills (toggle behavior, not navigation) let baseNavItems = $derived([ { href: '/', @@ -207,6 +216,13 @@ onClick: handleFilterToggle, active: isFilterStripVisible, }, + { + href: '/', + label: 'Tags', + icon: 'tag', + onClick: handleTagStripToggle, + active: isTagStripVisible, + }, ]); // Navigation items filtered by visibility settings (with fallback for guest mode) @@ -353,8 +369,28 @@ ariaLabel="Hauptnavigation" /> - - + + {#if isTagStripVisible} + ({ + id: l.id, + name: l.name, + color: l.color || '#6b7280', + }))} + selectedIds={viewStore.filterLabelIds} + onToggle={(tagId) => { + const current = viewStore.filterLabelIds; + if (current.includes(tagId)) { + viewStore.setFilterLabelIds(current.filter((id) => id !== tagId)); + } else { + viewStore.setFilterLabelIds([...current, tagId]); + } + }} + onClear={() => viewStore.setFilterLabelIds([])} + managementHref="/tags" + aboveFilterStrip={isFilterStripVisible} + /> + {/if} {#if isFilterStripVisible} diff --git a/packages/shared-stores/package.json b/packages/shared-stores/package.json index ce3f336a5..b7de4cf9c 100644 --- a/packages/shared-stores/package.json +++ b/packages/shared-stores/package.json @@ -16,6 +16,7 @@ "typescript": "^5.0.0" }, "dependencies": { - "@manacore/shared-auth": "workspace:*" + "@manacore/shared-auth": "workspace:*", + "@manacore/shared-tags": "workspace:*" } } diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index 6e69c4aff..a066b9809 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -20,3 +20,4 @@ export { type SimpleNavigationStores, type SimpleNavigationOptions, } from './navigation-simple'; +export { createTagStore, type TagStore, type TagStoreConfig } from './tags.svelte'; diff --git a/packages/shared-stores/src/tags.svelte.ts b/packages/shared-stores/src/tags.svelte.ts new file mode 100644 index 000000000..2b1454368 --- /dev/null +++ b/packages/shared-stores/src/tags.svelte.ts @@ -0,0 +1,249 @@ +/** + * Tag Store Factory + * Creates a tag store that uses the central mana-core-auth Tags API. + * Replaces app-specific tag/label stores with a unified, cross-app implementation. + */ + +import type { + Tag, + TagGroup, + CreateTagInput, + UpdateTagInput, + CreateTagGroupInput, + UpdateTagGroupInput, +} from '@manacore/shared-tags'; +import { TagsClient } from '@manacore/shared-tags'; + +export interface TagStoreConfig { + /** Base URL of mana-core-auth (e.g., 'http://localhost:3001') */ + authUrl: string; + /** Function to get the current auth token */ + getToken: () => Promise | string | null; + /** Create default tags on first fetch if user has none (default: true) */ + createDefaults?: boolean; +} + +export interface TagStore { + readonly tags: Tag[]; + readonly groups: TagGroup[]; + readonly loading: boolean; + readonly error: string | null; + + // Tags + fetchTags(): Promise; + getById(id: string): Tag | undefined; + getByIds(ids: string[]): Tag[]; + getColor(id: string): string; + createTag(data: CreateTagInput): Promise; + updateTag(id: string, data: UpdateTagInput): Promise; + deleteTag(id: string): Promise; + + // Groups + fetchGroups(): Promise; + createGroup(data: CreateTagGroupInput): Promise; + updateGroup(id: string, data: UpdateTagGroupInput): Promise; + deleteGroup(id: string): Promise; + reorderGroups(ids: string[]): Promise; + + // Utility + getTagsByGroup(groupId: string | null): Tag[]; + clear(): void; +} + +/** + * Create a tag store backed by the central mana-core-auth Tags API. + * + * @example + * ```ts + * import { createTagStore } from '@manacore/shared-stores'; + * + * export const tagStore = createTagStore({ + * authUrl: 'http://localhost:3001', + * getToken: () => authStore.getValidToken(), + * }); + * ``` + */ +export function createTagStore(config: TagStoreConfig): TagStore { + const { createDefaults = true } = config; + + let client: TagsClient | null = null; + + function getClient(): TagsClient { + if (!client) { + client = new TagsClient({ + authUrl: config.authUrl, + getToken: async () => { + const token = await config.getToken(); + return token || ''; + }, + }); + } + return client; + } + + let tags = $state([]); + let groups = $state([]); + let loading = $state(false); + let error = $state(null); + + return { + get tags() { + return tags; + }, + get groups() { + return groups; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + // === Tags === + + async fetchTags() { + loading = true; + error = null; + try { + let result = await getClient().getAll(); + + // Create defaults if user has no tags + if (result.length === 0 && createDefaults) { + result = await getClient().createDefaults(); + } + + tags = result; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch tags'; + console.error('Failed to fetch tags:', e); + } finally { + loading = false; + } + }, + + getById(id: string): Tag | undefined { + return tags.find((t) => t.id === id); + }, + + getByIds(ids: string[]): Tag[] { + return tags.filter((t) => ids.includes(t.id)); + }, + + getColor(id: string): string { + return tags.find((t) => t.id === id)?.color || '#6b7280'; + }, + + async createTag(data: CreateTagInput): Promise { + error = null; + try { + const tag = await getClient().create(data); + tags = [...tags, tag]; + return tag; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create tag'; + throw e; + } + }, + + async updateTag(id: string, data: UpdateTagInput): Promise { + error = null; + try { + const tag = await getClient().update(id, data); + tags = tags.map((t) => (t.id === id ? tag : t)); + return tag; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update tag'; + throw e; + } + }, + + async deleteTag(id: string): Promise { + error = null; + try { + await getClient().delete(id); + tags = tags.filter((t) => t.id !== id); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete tag'; + throw e; + } + }, + + // === Groups === + + async fetchGroups() { + error = null; + try { + groups = await getClient().getGroups(); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch tag groups'; + console.error('Failed to fetch tag groups:', e); + } + }, + + async createGroup(data: CreateTagGroupInput): Promise { + error = null; + try { + const group = await getClient().createGroup(data); + groups = [...groups, group]; + return group; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create tag group'; + throw e; + } + }, + + async updateGroup(id: string, data: UpdateTagGroupInput): Promise { + error = null; + try { + const group = await getClient().updateGroup(id, data); + groups = groups.map((g) => (g.id === id ? group : g)); + return group; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update tag group'; + throw e; + } + }, + + async deleteGroup(id: string): Promise { + error = null; + try { + await getClient().deleteGroup(id); + groups = groups.filter((g) => g.id !== id); + // Tags in deleted group lose their groupId + tags = tags.map((t) => (t.groupId === id ? { ...t, groupId: null } : t)); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete tag group'; + throw e; + } + }, + + async reorderGroups(ids: string[]): Promise { + error = null; + try { + await getClient().reorderGroups(ids); + // Re-sort local groups to match + groups = ids + .map((id) => groups.find((g) => g.id === id)) + .filter((g): g is TagGroup => g !== undefined); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to reorder groups'; + throw e; + } + }, + + // === Utility === + + getTagsByGroup(groupId: string | null): Tag[] { + return tags.filter((t) => (t.groupId || null) === groupId); + }, + + clear() { + tags = []; + groups = []; + loading = false; + error = null; + client = null; + }, + }; +}