diff --git a/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte index 88163d521..9e5c333ad 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte @@ -1,10 +1,9 @@
- +
- - contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)} - placeholder={$_('filters.allTags')} - embedded={true} - direction="up" - /> - + import { tagsStore } from '$lib/stores/tags.svelte'; + import { contactsFilterStore } from '$lib/stores/filter.svelte'; + import { goto } from '$app/navigation'; + import { onMount } from 'svelte'; + import { DotsThree, Plus, X } from '@manacore/shared-icons'; + import TagStripModal from './TagStripModal.svelte'; + + let showModal = $state(false); + + function handleTagClick(tagId: string) { + if (contactsFilterStore.selectedTagId === tagId) { + contactsFilterStore.setSelectedTagId(null); + } else { + contactsFilterStore.setSelectedTagId(tagId); + } + } + + function isTagSelected(tagId: string): boolean { + return contactsFilterStore.selectedTagId === tagId; + } + + const hasSelectedTags = $derived(contactsFilterStore.selectedTagId !== null); + + function handleOpenModal() { + showModal = true; + } + + function handleCloseModal() { + showModal = false; + } + + const sortedTags = $derived.by(() => { + return [...tagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de')); + }); + + const hasTags = $derived(tagsStore.tags.length > 0); + + onMount(async () => { + if (tagsStore.tags.length === 0) { + await tagsStore.fetchTags(); + } + }); + + +
+
+ + + + + + + {#if tagsStore.loading} +
Lädt...
+ {:else if !hasTags} + + {:else} + {#each sortedTags as tag (tag.id)} + + {/each} + + + + {/if} +
+
+ + + + + diff --git a/apps/contacts/apps/web/src/lib/components/TagStripModal.svelte b/apps/contacts/apps/web/src/lib/components/TagStripModal.svelte new file mode 100644 index 000000000..eef4b4cfb --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/TagStripModal.svelte @@ -0,0 +1,802 @@ + + + + +{#if visible} + + + + + + + +{/if} + + diff --git a/apps/contacts/apps/web/src/lib/stores/tags.svelte.ts b/apps/contacts/apps/web/src/lib/stores/tags.svelte.ts new file mode 100644 index 000000000..64d3329e0 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/tags.svelte.ts @@ -0,0 +1,117 @@ +/** + * Tags Store - Manages tag state using Svelte 5 runes + * + * Centralized store for tags, used by TagStrip, TagStripModal, and tags page. + * Uses the central Tags API from mana-core-auth. + */ + +import { tagsApi, type ContactTag } from '$lib/api/contacts'; + +// State +let tags = $state([]); +let loading = $state(false); +let error = $state(null); + +export const tagsStore = { + // Getters + get tags() { + return tags; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + /** + * Fetch all tags from API + */ + async fetchTags() { + loading = true; + error = null; + try { + const response = await tagsApi.list(); + tags = response.tags || []; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch tags'; + console.error('Failed to fetch tags:', e); + } finally { + loading = false; + } + }, + + /** + * Get tag by ID + */ + getById(id: string): ContactTag | undefined { + return tags.find((t) => t.id === id); + }, + + /** + * Get tag color by ID + */ + getColor(tagId: string): string { + const tag = tags.find((t) => t.id === tagId); + return tag?.color || '#6b7280'; + }, + + /** + * Create a new tag + */ + async createTag(data: { name: string; color?: string }) { + loading = true; + error = null; + try { + const response = await tagsApi.create(data); + tags = [...tags, response.tag]; + return response.tag; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create tag'; + console.error('Failed to create tag:', e); + throw e; + } finally { + loading = false; + } + }, + + /** + * Update an existing tag + */ + async updateTag(id: string, data: { name?: string; color?: string }) { + error = null; + try { + const response = await tagsApi.update(id, data); + tags = tags.map((t) => (t.id === id ? response.tag : t)); + return response.tag; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update tag'; + console.error('Failed to update tag:', e); + throw e; + } + }, + + /** + * Delete a tag + */ + async deleteTag(id: string) { + error = null; + try { + await tagsApi.delete(id); + tags = tags.filter((t) => t.id !== id); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete tag'; + console.error('Failed to delete tag:', e); + throw e; + } + }, + + /** + * Clear all state (for logout) + */ + clear() { + tags = []; + loading = false; + error = null; + }, +}; diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index 703058023..a193f6539 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -42,6 +42,8 @@ formatParsedContactPreview, } from '$lib/utils/contact-parser'; import ContactsToolbar from '$lib/components/ContactsToolbar.svelte'; + import TagStrip from '$lib/components/TagStrip.svelte'; + import { tagsStore } from '$lib/stores/tags.svelte'; import { contactsOnboarding } from '$lib/stores/app-onboarding.svelte'; import { MiniOnboardingModal } from '@manacore/shared-app-onboarding'; @@ -74,8 +76,8 @@ showContactsToolbar && !contactsFilterStore.isToolbarCollapsed ); - // Dynamic bottom offset based on toolbar state - const inputBarBottomOffset = $derived(isToolbarExpanded ? '140px' : '70px'); + // Dynamic bottom offset based on toolbar state (TagStrip adds ~40px) + const inputBarBottomOffset = $derived(isToolbarExpanded ? '180px' : '110px'); // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -281,13 +283,9 @@ // Load user settings and tags await userSettings.load(); - // Load tags for Quick-Create - try { - const tagsResult = await tagsApi.list(); - availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name })); - } catch (e) { - console.error('Failed to load tags:', e); - } + // Load tags (used by TagStrip and Quick-Create) + await tagsStore.fetchTags(); + availableTags = tagsStore.tags.map((t) => ({ id: t.id, name: t.name })); }); @@ -338,6 +336,9 @@ ariaLabel="Hauptnavigation" /> + + + import { onMount } from 'svelte'; import { _ } from 'svelte-i18n'; - import { tagsApi } from '$lib/api/contacts'; import type { ContactTag } from '$lib/api/contacts'; - import { - TagList, - TagEditModal, - TagColorPicker, - DEFAULT_TAG_COLOR, - type Tag, - } from '@manacore/shared-ui'; + import { tagsStore } from '$lib/stores/tags.svelte'; + import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui'; import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons'; - let loading = $state(true); - let tags = $state([]); - let error = $state(null); let searchQuery = $state(''); // Modal state @@ -22,24 +13,11 @@ let editingTag = $state(null); const filteredTags = $derived.by(() => { - if (!searchQuery.trim()) return tags; + if (!searchQuery.trim()) return tagsStore.tags; const query = searchQuery.toLowerCase(); - return tags.filter((t) => t.name.toLowerCase().includes(query)); + return tagsStore.tags.filter((t) => t.name.toLowerCase().includes(query)); }); - async function loadTags() { - loading = true; - error = null; - try { - const response = await tagsApi.list(); - tags = response.tags || []; - } catch (e) { - error = e instanceof Error ? e.message : $_('messages.error'); - } finally { - loading = false; - } - } - function openCreateModal() { editingTag = null; showModal = true; @@ -56,18 +34,15 @@ } async function handleSave(name: string, color: string) { - error = null; try { if (editingTag) { - const response = await tagsApi.update(editingTag.id, { name, color }); - tags = tags.map((t) => (t.id === editingTag!.id ? response.tag : t)); + await tagsStore.updateTag(editingTag.id, { name, color }); } else { - const response = await tagsApi.create({ name, color }); - tags = [...tags, response.tag]; + await tagsStore.createTag({ name, color }); } closeModal(); } catch (e) { - error = e instanceof Error ? e.message : $_('messages.error'); + console.error('Failed to save tag:', e); } } @@ -75,11 +50,10 @@ if (!editingTag) return; try { - await tagsApi.delete(editingTag.id); - tags = tags.filter((t) => t.id !== editingTag!.id); + await tagsStore.deleteTag(editingTag.id); closeModal(); } catch (e) { - error = e instanceof Error ? e.message : $_('messages.error'); + console.error('Failed to delete tag:', e); } } @@ -87,14 +61,17 @@ if (!confirm($_('tags.confirmDelete', { values: { name: tag.name } }))) return; try { - await tagsApi.delete(tag.id); - tags = tags.filter((t) => t.id !== tag.id); + await tagsStore.deleteTag(tag.id); } catch (e) { - error = e instanceof Error ? e.message : $_('messages.error'); + console.error('Failed to delete tag:', e); } } - onMount(loadTags); + onMount(() => { + if (tagsStore.tags.length === 0) { + tagsStore.fetchTags(); + } + }); @@ -124,16 +101,16 @@ />
- {#if error} + {#if tagsStore.error} {/if} openEditModal(tag as ContactTag)} onDelete={handleDeleteFromList} emptyMessage={searchQuery ? $_('tags.noResults') : $_('tags.noTags')} @@ -142,14 +119,14 @@ : $_('tags.createFirst')} /> - {#if !loading && tags.length > 0} + {#if !tagsStore.loading && tagsStore.tags.length > 0}

- {tags.length} - {tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')} + {tagsStore.tags.length} + {tagsStore.tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}

{/if} - {#if !loading && tags.length === 0 && !searchQuery} + {#if !tagsStore.loading && tagsStore.tags.length === 0 && !searchQuery}
+ + + + + {#if labelsStore.loading} +
Lädt...
+ {:else if !hasTags} + + {:else} + {#each sortedTags as tag (tag.id)} + + {/each} + + + + {/if} +
+
+ + + + + diff --git a/apps/todo/apps/web/src/lib/components/TagStripModal.svelte b/apps/todo/apps/web/src/lib/components/TagStripModal.svelte new file mode 100644 index 000000000..964580917 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/TagStripModal.svelte @@ -0,0 +1,802 @@ + + + + +{#if visible} + + + + + + + +{/if} + + diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 28de21b4e..530ff00d2 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -24,6 +24,7 @@ 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 { @@ -357,6 +358,9 @@ ariaLabel="Hauptnavigation" /> + + + {#if isFilterStripVisible} {/if}