mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
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:
parent
1316ef57f3
commit
ce900d5fd3
5 changed files with 333 additions and 103 deletions
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*"
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ export {
|
|||
type SimpleNavigationStores,
|
||||
type SimpleNavigationOptions,
|
||||
} from './navigation-simple';
|
||||
export { createTagStore, type TagStore, type TagStoreConfig } from './tags.svelte';
|
||||
|
|
|
|||
249
packages/shared-stores/src/tags.svelte.ts
Normal file
249
packages/shared-stores/src/tags.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue