feat(todo): integrate shared TagStrip and createTagStore

- Add createTagStore factory to @manacore/shared-stores (Svelte 5 runes, backed by @manacore/shared-tags)
- Replace Todo's local TagStrip with shared TagStrip from @manacore/shared-ui
- Replace Todo's labels store with createTagStore wrapper (backward-compatible)
- Remove "Tags" tab from PillNav TabGroup, add it as toggle pill (like Filter)
- Tags pill toggles TagStrip overlay visibility instead of navigating to /tags
- TagStrip has "Tags verwalten" pill linking to /tags management page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 20:51:55 +01:00
parent 1316ef57f3
commit ce900d5fd3
5 changed files with 333 additions and 103 deletions

View file

@ -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<Label[]>([]);
let loading = $state(false);
let error = $state<string | null>(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();
},
};

View file

@ -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<string, string> = {
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<PillNavElement>({
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<PillNavItem[]>([
{
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"
/>
<!-- TagStrip (above PillNav, always visible when PillNav is open) -->
<TagStrip filterStripVisible={isFilterStripVisible} />
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
tags={labelsStore.labels.map((l) => ({
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}
<!-- TaskFilters strip (shown when Filter pill is active in PillNav) -->
{#if isFilterStripVisible}

View file

@ -16,6 +16,7 @@
"typescript": "^5.0.0"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*"
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-tags": "workspace:*"
}
}

View file

@ -20,3 +20,4 @@ export {
type SimpleNavigationStores,
type SimpleNavigationOptions,
} from './navigation-simple';
export { createTagStore, type TagStore, type TagStoreConfig } from './tags.svelte';

View file

@ -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> | 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<void>;
getById(id: string): Tag | undefined;
getByIds(ids: string[]): Tag[];
getColor(id: string): string;
createTag(data: CreateTagInput): Promise<Tag>;
updateTag(id: string, data: UpdateTagInput): Promise<Tag>;
deleteTag(id: string): Promise<void>;
// Groups
fetchGroups(): Promise<void>;
createGroup(data: CreateTagGroupInput): Promise<TagGroup>;
updateGroup(id: string, data: UpdateTagGroupInput): Promise<TagGroup>;
deleteGroup(id: string): Promise<void>;
reorderGroups(ids: string[]): Promise<void>;
// 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<Tag[]>([]);
let groups = $state<TagGroup[]>([]);
let loading = $state(false);
let error = $state<string | null>(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<Tag> {
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<Tag> {
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<void> {
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<TagGroup> {
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<TagGroup> {
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<void> {
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<void> {
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;
},
};
}