feat(local-first): migrate tags + task stores to reactive liveQuery across all apps

- Todo: Replace manual fetch/state stores with useLiveQuery() for tasks,
  projects, and tags. Components use Svelte context instead of store imports.
  Stores reduced to mutation-only services. Removes ~200 lines of manual
  state management. Enables multi-tab sync and auto-refresh on data changes.

- Tags (all 16 apps): Migrate from API-based createTagStore() to shared
  local-first IndexedDB ('manacore-tags'). Tags now work offline and in
  guest mode with default seed data. All apps share the same tag DB via
  tagLocalStore + useAllTags() + setContext pattern.

- Cleanup: Delete unused Todo API files (projects.ts, labels.ts,
  reminders.ts), remove dead labels store, clean up barrel exports.

Apps migrated: Todo, Zitare, Questions, Planta, Clock, Presi, Mukke,
Context, CityCorners, ManaDeck, Chat, Contacts, Calendar, Picture,
Storage, Photos

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 02:02:52 +01:00
parent 32939fbfb5
commit 5c33962439
83 changed files with 1896 additions and 3937 deletions

View file

@ -1,11 +1,14 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { DotsThree, Plus, X } from '@manacore/shared-icons';
import TagStripModal from './TagStripModal.svelte';
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
let showModal = $state(false);
function handleTagClick(tagId: string) {
@ -27,16 +30,10 @@
}
const sortedTags = $derived.by(() => {
return [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
return [...tagsCtx.value].sort((a, b) => a.name.localeCompare(b.name, 'de'));
});
const hasTags = $derived(eventTagsStore.tags.length > 0);
onMount(async () => {
if (eventTagsStore.tags.length === 0) {
await eventTagsStore.fetchTags();
}
});
const hasTags = $derived(tagsCtx.value.length > 0);
</script>
<div class="tag-strip-wrapper">
@ -59,9 +56,7 @@
<span class="tag-name">Alle Tags</span>
</button>
{#if eventTagsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if !hasTags}
{#if !hasTags}
<button class="empty-state glass-tag" onclick={() => goto('/tags')}>
<span>Keine Tags vorhanden</span>
<span class="add-hint">+ Erstellen</span>

View file

@ -1,8 +1,12 @@
<script lang="ts">
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
import { Plus, X, Check, Pencil, Trash, MagnifyingGlass } from '@manacore/shared-icons';
import { TagColorPicker, focusTrap } from '@manacore/shared-ui';
import type { EventTag } from '@calendar/shared';
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
interface Props {
visible: boolean;
@ -21,14 +25,14 @@
let isCreatingTag = $state(false);
// Edit tag state
let editingTag = $state<EventTag | null>(null);
let editingTag = $state<Tag | null>(null);
let editTagName = $state('');
let editTagColor = $state('#3b82f6');
let isSavingTag = $state(false);
// Filtered and sorted tags
const sortedTags = $derived.by(() => {
const tags = [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
const tags = [...tagsCtx.value].sort((a, b) => a.name.localeCompare(b.name, 'de'));
if (!searchQuery.trim()) return tags;
const query = searchQuery.toLowerCase();
return tags.filter((t) => t.name.toLowerCase().includes(query));
@ -51,17 +55,15 @@
if (!newTagName.trim() || isCreatingTag) return;
isCreatingTag = true;
const result = await eventTagsStore.createTag({
name: newTagName.trim(),
color: newTagColor,
});
if (result.error) {
console.error('Failed to create tag:', result.error);
} else {
try {
await tagMutations.createTag({
name: newTagName.trim(),
color: newTagColor,
});
closeNewTagForm();
} catch (e) {
console.error('Failed to create tag:', e);
}
isCreatingTag = false;
}
@ -76,7 +78,7 @@
}
// ==================== EDIT TAG ====================
function openEditTag(tag: EventTag) {
function openEditTag(tag: Tag) {
editingTag = tag;
editTagName = tag.name;
editTagColor = tag.color;
@ -92,29 +94,26 @@
if (!editingTag || !editTagName.trim() || isSavingTag) return;
isSavingTag = true;
const result = await eventTagsStore.updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
});
if (result.error) {
console.error('Failed to update tag:', result.error);
} else {
try {
await tagMutations.updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
});
closeEditTag();
} catch (e) {
console.error('Failed to update tag:', e);
}
isSavingTag = false;
}
async function handleDeleteTag() {
if (!editingTag) return;
const result = await eventTagsStore.deleteTag(editingTag.id);
if (result.error) {
console.error('Failed to delete tag:', result.error);
} else {
try {
await tagMutations.deleteTag(editingTag.id);
closeEditTag();
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
@ -169,9 +168,7 @@
<!-- Content -->
<div class="modal-content">
{#if eventTagsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if eventTagsStore.tags.length === 0 && !showNewTagForm}
{#if tagsCtx.value.length === 0 && !showNewTagForm}
<div class="empty-state">
<p>Keine Tags vorhanden</p>
<button class="create-btn" onclick={openNewTagForm}>

View file

@ -1,9 +1,12 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { getContext, type Snippet } from 'svelte';
import { QuickInputBar, TagStrip } from '@manacore/shared-ui';
import type { QuickInputItem, CreatePreview } from '@manacore/shared-ui';
import { unifiedBarStore } from '$lib/stores/unified-bar.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import type { Tag } from '@manacore/shared-tags';
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
import { settingsStore } from '$lib/stores/settings.svelte';
import DateStrip from './DateStrip.svelte';
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
@ -128,7 +131,7 @@
{#if showCalendarLayers && unifiedBarStore.showTagStrip}
<div class="unified-bar-layer tag-layer" transition:fly={flyConfig}>
<TagStrip
tags={eventTagsStore.tags.map((t) => ({
tags={tagsCtx.value.map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
@ -137,7 +140,6 @@
onToggle={(tagId) => settingsStore.toggleTagSelection(tagId)}
onClear={() => settingsStore.clearTagSelection()}
managementHref="/tags"
loading={eventTagsStore.loading}
/>
</div>
{/if}

View file

@ -1,8 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import type { Tag as SharedTag } from '@manacore/shared-tags';
// Live tags from layout context
const tagsCtx: { readonly value: SharedTag[] } = getContext('tags');
import {
TagSelector,
FilterDropdown,
@ -91,7 +94,7 @@
);
// Convert EventTag to Tag type for shared-ui components
function eventTagToTag(tag: EventTag): Tag {
function eventTagToTag(tag: SharedTag): Tag {
return {
id: tag.id,
name: tag.name,
@ -105,7 +108,7 @@
}
// Derived available tags for TagSelector
let availableTags = $derived(eventTagsStore.tags.map(eventTagToTag));
let availableTags = $derived(tagsCtx.value.map(eventTagToTag));
// Calendar options for FilterDropdown
let calendarOptions = $derived<FilterDropdownOption[]>(
@ -172,13 +175,6 @@
let submitting = $state(false);
// Load tags on mount
onMount(() => {
if (eventTagsStore.tags.length === 0) {
eventTagsStore.fetchTags();
}
});
function handleSubmit(e: Event) {
e.preventDefault();
@ -471,7 +467,7 @@
</div>
<!-- Tags -->
{#if availableTags.length > 0 || eventTagsStore.loading}
{#if availableTags.length > 0}
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">Tags</span>
<TagSelector

View file

@ -1,93 +1,11 @@
/**
* Event Tags Store - Uses shared Tag Store backed by central mana-core-auth
*
* Wraps createTagStore to provide a backward-compatible eventTagsStore interface
* that existing Calendar components (TagStripModal, EventForm, /tags page) rely on.
* Event Tags Store Local-First via Shared Tag Store
*/
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';
import type { EventTag } from '@calendar/shared';
// Re-export EventTag for backward compatibility
export type { EventTag };
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 wrapper that returns { data, error } results
* to match the old Calendar API client pattern used by TagStripModal.
*/
async function wrapResult<T>(
fn: () => Promise<T>
): Promise<{ data: T | null; error: { message: string } | null }> {
try {
const data = await fn();
return { data, error: null };
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error';
return { data: null, error: { message } };
}
}
// Backward-compatible eventTagsStore wrapper
export const eventTagsStore = {
get tags() {
return tagStore.tags as unknown as EventTag[];
},
get loading() {
return tagStore.loading;
},
get error() {
return tagStore.error;
},
async fetchTags() {
return tagStore.fetchTags();
},
getById(id: string) {
return tagStore.getById(id) as unknown as EventTag | undefined;
},
getByIds(ids: string[]) {
return tagStore.getByIds(ids) as unknown as EventTag[];
},
async createTag(data: { name: string; color?: string; groupId?: string | null }) {
return wrapResult(() => tagStore.createTag(data)) as Promise<{
data: EventTag | null;
error: { message: string } | null;
}>;
},
async updateTag(id: string, data: { name?: string; color?: string; groupId?: string | null }) {
return wrapResult(() => tagStore.updateTag(id, data)) as Promise<{
data: EventTag | null;
error: { message: string } | null;
}>;
},
async deleteTag(id: string) {
return wrapResult(() => tagStore.deleteTag(id));
},
clear() {
tagStore.clear();
},
};
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { setContext } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, InputBarHelpModal, ImmersiveModeToggle } from '@manacore/shared-ui';
import {
@ -22,7 +23,11 @@
import { viewStore } from '$lib/stores/view.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { settingsStore } from '$lib/stores/settings.svelte';
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
import { browser } from '$app/environment';
@ -72,6 +77,10 @@
splitPanel.openPanel(appId);
}
// Live tag query + context
const allTags = useAllSharedTags();
setContext('tags', allTags);
let { children } = $props();
// InputBar search - search events
@ -129,7 +138,7 @@
const resolved = resolveEventIds(
parsed,
calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name })),
eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name })),
allTags.value.map((t) => ({ id: t.id, name: t.name })),
defaultCalendarId
);
@ -285,12 +294,11 @@
// Tag selector config for PillNavigation
let tagSelectorConfig = $derived<PillTagSelectorConfig>({
type: 'tag-selector',
tags: eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name, color: t.color || '#3b82f6' })),
tags: allTags.value.map((t) => ({ id: t.id, name: t.name, color: t.color || '#3b82f6' })),
selectedIds: settingsStore.selectedTagIds,
onToggle: settingsStore.toggleTagSelection,
onClear: settingsStore.clearTagSelection,
onCreate: () => goto('/tags?new=true'),
loading: eventTagsStore.loading,
label: 'Tags',
});
@ -407,6 +415,7 @@
async function handleLogout() {
await authStore.signOut();
tagMutations.stopSync();
goto('/login');
}
@ -433,8 +442,8 @@
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await calendarStore.initialize();
// Initialize local-first databases (opens IndexedDB, seeds guest data)
await Promise.all([calendarStore.initialize(), tagLocalStore.initialize()]);
// Initialize split-panel from URL/localStorage
splitPanel.initialize();
@ -447,10 +456,11 @@
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
calendarStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
calendarStore.startSync(getToken);
tagMutations.startSync(getToken);
// Fetch tags and user settings (require auth)
await eventTagsStore.fetchTags();
// Load user settings (requires auth)
await userSettings.load();
} else if (shouldShowGuestWelcome('calendar')) {
showGuestWelcome = true;

View file

@ -1,17 +1,20 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import type { EventTag } from '@calendar/shared';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
let searchQuery = $state('');
let showTagModal = $state(false);
let editingTag = $state<EventTag | null>(null);
let editingTag = $state<Tag | null>(null);
// Filtered tags based on search, sorted alphabetically
const filteredTags = $derived.by(() => {
const sorted = [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
const sorted = [...tagsCtx.value].sort((a, b) => a.name.localeCompare(b.name, 'de'));
if (!searchQuery.trim()) return sorted;
const query = searchQuery.toLowerCase();
return sorted.filter((t) => t.name.toLowerCase().includes(query));
@ -35,7 +38,7 @@
showTagModal = true;
}
function openEditTagModal(tag: EventTag) {
function openEditTagModal(tag: Tag) {
editingTag = tag;
showTagModal = true;
}
@ -50,12 +53,12 @@
try {
if (editingTag) {
await eventTagsStore.updateTag(editingTag.id, {
await tagMutations.updateTag(editingTag.id, {
name: tagName.trim(),
color: tagColor,
});
} else {
await eventTagsStore.createTag({
await tagMutations.createTag({
name: tagName.trim(),
color: tagColor,
});
@ -72,7 +75,7 @@
if (!confirm(`Tag "${editingTag.name}" wirklich löschen?`)) return;
try {
await eventTagsStore.deleteTag(editingTag.id);
await tagMutations.deleteTag(editingTag.id);
closeTagModal();
} catch (e) {
console.error('Failed to delete tag:', e);
@ -87,12 +90,6 @@
}
const previewTag = $derived({ name: tagName || 'Tag Name', color: tagColor });
onMount(async () => {
if (eventTagsStore.tags.length === 0) {
await eventTagsStore.fetchTags();
}
});
</script>
<svelte:head>
@ -122,14 +119,14 @@
/>
</div>
{#if eventTagsStore.error}
{#if false}
<div class="error-banner" role="alert">
<span>{eventTagsStore.error}</span>
<span>{false}</span>
</div>
{/if}
<!-- Tag List -->
{#if eventTagsStore.loading}
{#if false}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
@ -150,14 +147,14 @@
</div>
{/if}
{#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}
{#if !false && tagsCtx.value.length > 0}
<p class="tags-count">
{eventTagsStore.tags.length}
{eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}
{tagsCtx.value.length}
{tagsCtx.value.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
{#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
{#if !false && tagsCtx.value.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateTagModal} class="btn btn-primary">
<Plus size={16} weight="bold" />

View file

@ -1,20 +1,11 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
*/
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -15,9 +15,14 @@
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { setContext } from 'svelte';
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 as useAllSharedTags,
} from '@manacore/shared-stores';
import { getPillAppItems } from '@manacore/shared-branding';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
@ -28,6 +33,10 @@
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { chatStore } from '$lib/data/local-store';
// Live tag query + context
const allTags = useAllSharedTags();
setContext('tags', allTags);
// App switcher items
const appItems = getPillAppItems('chat');
@ -155,16 +164,19 @@
async function handleLogout() {
await authStore.signOut();
tagMutations.stopSync();
goto('/login');
}
let showGuestWelcome = $state(false);
async function handleAuthReady() {
// Initialize local-first database
await chatStore.initialize();
// Initialize local-first databases
await Promise.all([chatStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
chatStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
chatStore.startSync(getToken);
tagMutations.startSync(getToken);
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('chat')) {
showGuestWelcome = true;
@ -182,9 +194,8 @@
if (!authStore.isAuthenticated) return;
// Load user settings and tags
// Load user settings
await userSettings.load();
await tagStore.fetchTags();
// Check for session conversations to migrate
if (conversationsStore.hasSessionConversations) {
@ -240,7 +251,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',
@ -249,7 +260,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,10 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +17,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -1,11 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { setContext } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { PillNavigation, QuickInputBar, TagStrip } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
@ -21,6 +25,9 @@
const appItems = getPillAppItems('citycorners');
const allTags = useAllSharedTags();
setContext('tags', allTags);
let { children } = $props();
let isDark = $derived(theme.isDark);
@ -178,10 +185,11 @@
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await citycornersStore.initialize();
await Promise.all([citycornersStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
citycornersStore.startSync(() => authStore.getValidToken());
await tagStore.fetchTags();
const getToken = () => authStore.getValidToken();
citycornersStore.startSync(getToken);
tagMutations.startSync(getToken);
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('citycorners')) {
showGuestWelcome = true;
@ -227,7 +235,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',
@ -236,7 +244,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,9 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +16,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -1,12 +1,10 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +17,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -1,11 +1,14 @@
<script lang="ts">
import { tagsStore } from '$lib/stores/tags.svelte';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
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';
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
let showModal = $state(false);
function handleTagClick(tagId: string) {
@ -31,16 +34,10 @@
}
const sortedTags = $derived.by(() => {
return [...tagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
return [...tagsCtx.value].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();
}
});
const hasTags = $derived(tagsCtx.value.length > 0);
</script>
<div class="tag-strip-wrapper">
@ -63,9 +60,7 @@
<span class="tag-name">Alle Tags</span>
</button>
{#if tagsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if !hasTags}
{#if !hasTags}
<button class="empty-state glass-tag" onclick={() => goto('/tags')}>
<span>Keine Tags vorhanden</span>
<span class="add-hint">+ Erstellen</span>

View file

@ -1,8 +1,12 @@
<script lang="ts">
import { tagsStore } from '$lib/stores/tags.svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
import { Plus, X, Check, Pencil, Trash, MagnifyingGlass } from '@manacore/shared-icons';
import { TagColorPicker } from '@manacore/shared-ui';
import type { ContactTag } from '$lib/api/contacts';
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
interface Props {
visible: boolean;
@ -21,14 +25,14 @@
let isCreatingTag = $state(false);
// Edit tag state
let editingTag = $state<ContactTag | null>(null);
let editingTag = $state<Tag | null>(null);
let editTagName = $state('');
let editTagColor = $state('#3b82f6');
let isSavingTag = $state(false);
// Filtered and sorted tags
const sortedTags = $derived.by(() => {
const tags = [...tagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
const tags = [...tagsCtx.value].sort((a, b) => a.name.localeCompare(b.name, 'de'));
if (!searchQuery.trim()) return tags;
const query = searchQuery.toLowerCase();
return tags.filter((t) => t.name.toLowerCase().includes(query));
@ -52,7 +56,7 @@
isCreatingTag = true;
try {
await tagsStore.createTag({
await tagMutations.createTag({
name: newTagName.trim(),
color: newTagColor,
});
@ -74,7 +78,7 @@
}
// ==================== EDIT TAG ====================
function openEditTag(tag: ContactTag) {
function openEditTag(tag: Tag) {
editingTag = tag;
editTagName = tag.name;
editTagColor = tag.color;
@ -91,7 +95,7 @@
isSavingTag = true;
try {
await tagsStore.updateTag(editingTag.id, {
await tagMutations.updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
});
@ -106,7 +110,7 @@
if (!editingTag) return;
try {
await tagsStore.deleteTag(editingTag.id);
await tagMutations.deleteTag(editingTag.id);
closeEditTag();
} catch (e) {
console.error('Failed to delete tag:', e);
@ -164,9 +168,7 @@
<!-- Content -->
<div class="modal-content">
{#if tagsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if tagsStore.tags.length === 0 && !showNewTagForm}
{#if tagsCtx.value.length === 0 && !showNewTagForm}
<div class="empty-state">
<p>Keine Tags vorhanden</p>
<button class="create-btn" onclick={openNewTagForm}>

View file

@ -1,65 +1,11 @@
/**
* Tags Store - Uses shared Tag Store backed by central mana-core-auth
*
* Centralized store for tags, used by TagStrip, TagStripModal, and tags page.
* Wraps createTagStore for backward compatibility with existing ContactTag interface.
* Tag Store Local-First via Shared Tag Store
*/
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';
// Re-export Tag as ContactTag for backward compatibility
export type ContactTag = 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 tagsStore wrapper
export const tagsStore = {
get tags() {
return tagStore.tags;
},
get loading() {
return tagStore.loading;
},
get error() {
return tagStore.error;
},
async fetchTags() {
return tagStore.fetchTags();
},
getById(id: string) {
return tagStore.getById(id);
},
getColor(tagId: string) {
return tagStore.getColor(tagId);
},
async createTag(data: { name: string; color?: string }) {
return tagStore.createTag(data);
},
async updateTag(id: string, data: { name?: string; color?: string }) {
return tagStore.updateTag(id, data);
},
async deleteTag(id: string) {
return tagStore.deleteTag(id);
},
clear() {
tagStore.clear();
},
};
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { setContext } from 'svelte';
import {
PillNavigation,
QuickInputBar,
@ -46,7 +47,11 @@
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
import ContactsToolbar from '$lib/components/ContactsToolbar.svelte';
import { tagsStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { contactsOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
@ -62,8 +67,9 @@
}
}
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
// Live tag query + context
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Check if we're on a contact detail route
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
@ -215,6 +221,7 @@
async function handleLogout() {
await authStore.signOut();
tagMutations.stopSync();
goto('/login');
}
@ -294,12 +301,14 @@
});
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await contactsLocalStore.initialize();
// Initialize local-first databases (opens IndexedDB, seeds guest data)
await Promise.all([contactsLocalStore.initialize(), tagLocalStore.initialize()]);
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
contactsLocalStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
contactsLocalStore.startSync(getToken);
tagMutations.startSync(getToken);
}
// Initialize split-panel from URL/localStorage
@ -316,11 +325,9 @@
// Load contacts from IndexedDB (guest seed or synced data)
await contactsStore.loadContacts();
// Load user settings and tags only when authenticated
// Load user settings only when authenticated
if (authStore.isAuthenticated) {
await userSettings.load();
await tagsStore.fetchTags();
availableTags = tagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
}
}
</script>
@ -378,7 +385,7 @@
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
tags={tagsStore.tags.map((t) => ({
tags={allTags.value.map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',

View file

@ -1,21 +1,24 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { ContactTag } from '$lib/api/contacts';
import { tagsStore } from '$lib/stores/tags.svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag as SharedTag } from '@manacore/shared-tags';
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
// Live tags from layout context
const tagsCtx: { readonly value: SharedTag[] } = getContext('tags');
let searchQuery = $state('');
// Modal state
let showModal = $state(false);
let editingTag = $state<ContactTag | null>(null);
let editingTag = $state<SharedTag | null>(null);
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return tagsStore.tags;
if (!searchQuery.trim()) return tagsCtx.value;
const query = searchQuery.toLowerCase();
return tagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
return tagsCtx.value.filter((t) => t.name.toLowerCase().includes(query));
});
function openCreateModal() {
@ -23,7 +26,7 @@
showModal = true;
}
function openEditModal(tag: ContactTag) {
function openEditModal(tag: SharedTag) {
editingTag = tag;
showModal = true;
}
@ -36,9 +39,9 @@
async function handleSave(name: string, color: string) {
try {
if (editingTag) {
await tagsStore.updateTag(editingTag.id, { name, color });
await tagMutations.updateTag(editingTag.id, { name, color });
} else {
await tagsStore.createTag({ name, color });
await tagMutations.createTag({ name, color });
}
closeModal();
} catch (e) {
@ -50,7 +53,7 @@
if (!editingTag) return;
try {
await tagsStore.deleteTag(editingTag.id);
await tagMutations.deleteTag(editingTag.id);
closeModal();
} catch (e) {
console.error('Failed to delete tag:', e);
@ -61,17 +64,11 @@
if (!confirm($_('tags.confirmDelete', { values: { name: tag.name } }))) return;
try {
await tagsStore.deleteTag(tag.id);
await tagMutations.deleteTag(tag.id);
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
onMount(() => {
if (tagsStore.tags.length === 0) {
tagsStore.fetchTags();
}
});
</script>
<svelte:head>
@ -101,17 +98,10 @@
/>
</div>
{#if tagsStore.error}
<div class="error-banner" role="alert">
<span>{tagsStore.error}</span>
</div>
{/if}
<!-- Tag List using shared component -->
<TagList
tags={filteredTags}
loading={tagsStore.loading}
onEdit={(tag) => openEditModal(tag as ContactTag)}
onEdit={(tag) => openEditModal(tag as SharedTag)}
onDelete={handleDeleteFromList}
emptyMessage={searchQuery ? $_('tags.noResults') : $_('tags.noTags')}
emptyDescription={searchQuery
@ -119,14 +109,14 @@
: $_('tags.createFirst')}
/>
{#if !tagsStore.loading && tagsStore.tags.length > 0}
{#if tagsCtx.value.length > 0}
<p class="tags-count">
{tagsStore.tags.length}
{tagsStore.tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}
{tagsCtx.value.length}
{tagsCtx.value.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}
</p>
{/if}
{#if !tagsStore.loading && tagsStore.tags.length === 0 && !searchQuery}
{#if tagsCtx.value.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { onMount, setContext } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, CommandBar, TagStrip } from '@manacore/shared-ui';
import type {
@ -31,10 +31,17 @@
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { contextStore } from '$lib/data/local-store';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
const appItems = getPillAppItems('context');
const allTags = useAllSharedTags();
setContext('tags', allTags);
let { children } = $props();
let commandBarOpen = $state(false);
@ -218,9 +225,11 @@
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await contextStore.initialize();
await Promise.all([contextStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
contextStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
contextStore.startSync(getToken);
tagMutations.startSync(getToken);
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('context')) {
showGuestWelcome = true;
@ -234,7 +243,6 @@
if (authStore.isAuthenticated) {
await userSettings.load();
await tagStore.fetchTags();
await Promise.all([spacesStore.load(), documentsStore.load()]);
}
}
@ -280,7 +288,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',
@ -289,7 +297,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,9 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +16,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -2,13 +2,18 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { setContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { theme } from '$lib/stores/theme';
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { PillNavigation, QuickInputBar, TagStrip } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -29,6 +34,9 @@
// App switcher items
const appItems = getPillAppItems('manadeck');
const allTags = useAllSharedTags();
setContext('tags', allTags);
let { children } = $props();
let isCollapsed = $state(false);
@ -177,12 +185,14 @@
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await manadeckStore.initialize();
// Initialize local-first database and shared tag store
await Promise.all([manadeckStore.initialize(), tagLocalStore.initialize()]);
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
manadeckStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
manadeckStore.startSync(getToken);
tagMutations.startSync(getToken);
}
// Load decks from IndexedDB (guest seed or synced data)
@ -194,9 +204,8 @@
}
if (authStore.isAuthenticated) {
// Load user settings and tags (require auth)
// Load user settings (require auth)
await userSettings.load();
await tagStore.fetchTags();
}
// Redirect to start page if on root and a custom start page is set
@ -255,7 +264,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',
@ -264,7 +273,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,9 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +16,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { setContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { PillNavigation, QuickInputBar, DevBuildBadge, TagStrip } from '@manacore/shared-ui';
@ -29,13 +30,21 @@
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { mukkeStore } from '$lib/data/local-store';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import MiniPlayer from '$lib/components/MiniPlayer.svelte';
import FullPlayer from '$lib/components/FullPlayer.svelte';
import QueuePanel from '$lib/components/QueuePanel.svelte';
let { children } = $props();
// Shared tag store (local-first)
const allTags = useAllSharedTags();
setContext('tags', allTags);
// App switcher items
const appItems = getPillAppItems('mukke' as any);
@ -185,17 +194,16 @@
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await mukkeStore.initialize();
await Promise.all([mukkeStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
mukkeStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
mukkeStore.startSync(getToken);
tagMutations.startSync(getToken);
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('mukke')) {
showGuestWelcome = true;
}
splitPanel.initialize();
if (authStore.isAuthenticated) {
await tagStore.fetchTags();
}
}
</script>
@ -242,7 +250,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',
@ -251,7 +259,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,10 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +17,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -3,7 +3,6 @@
import { format } from 'date-fns';
import type { Photo } from '@photos/shared';
import { photoStore } from '$lib/stores/photos.svelte';
import { tagStore } from '$lib/stores/tags.svelte';
interface Props {
photo: Photo;

View file

@ -1,91 +1,28 @@
/**
* Tags Store - Uses shared Tag Store backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
*
* Wraps createTagStore for backward compatibility with existing tagStore interface.
* Also preserves photo-specific tag operations (getPhotoTags, addTagToPhoto, etc.)
* which go through the Photos backend, not mana-core-auth.
* Core tag CRUD is handled by the shared local-first tag store.
* Photo-specific tag operations (junction table) go through the Photos backend.
*/
import { browser } from '$app/environment';
import { createTagStore, type TagStore } from '@manacore/shared-stores';
import { authStore } from '$lib/stores/auth.svelte';
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';
import { api } from '$lib/api/client';
import type { Tag } from '@photos/shared';
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 sharedTagStore: TagStore = createTagStore({
authUrl: getAuthUrl(),
getToken: () => authStore.getValidToken(),
});
// Backward-compatible tagStore wrapper
export const tagStore = {
// Getters (delegate to shared store)
get tags() {
return sharedTagStore.tags;
},
get loading() {
return sharedTagStore.loading;
},
get error() {
return sharedTagStore.error;
},
/**
* Load all tags
*/
async loadTags() {
return sharedTagStore.fetchTags();
},
/**
* Create new tag
*/
async createTag(data: { name: string; color?: string }) {
try {
const tag = await sharedTagStore.createTag(data);
return tag;
} catch {
return null;
}
},
/**
* Update tag
*/
async updateTag(id: string, data: { name?: string; color?: string }) {
try {
const tag = await sharedTagStore.updateTag(id, data);
return tag;
} catch {
return null;
}
},
/**
* Delete tag
*/
async deleteTag(id: string) {
try {
await sharedTagStore.deleteTag(id);
return true;
} catch {
return false;
}
},
/**
* Get tags for a photo (from Photos backend)
*/
/**
* Photo-specific tag operations (junction table: photo <-> tag).
* These go through the Photos backend, not the shared tag store.
*/
export const photoTagOps = {
/** Get tags for a photo */
async getPhotoTags(mediaId: string): Promise<Tag[]> {
try {
const result = await api.get<Tag[]>(`/photos/${mediaId}/tags`);
@ -99,9 +36,7 @@ export const tagStore = {
}
},
/**
* Add tag to photo (from Photos backend)
*/
/** Add tag to photo */
async addTagToPhoto(mediaId: string, tagId: string) {
try {
const result = await api.post(`/photos/${mediaId}/tags/${tagId}`);
@ -112,9 +47,7 @@ export const tagStore = {
}
},
/**
* Remove tag from photo (from Photos backend)
*/
/** Remove tag from photo */
async removeTagFromPhoto(mediaId: string, tagId: string) {
try {
const result = await api.delete(`/photos/${mediaId}/tags/${tagId}`);
@ -125,9 +58,7 @@ export const tagStore = {
}
},
/**
* Set all tags for a photo (from Photos backend)
*/
/** Set all tags for a photo */
async setPhotoTags(mediaId: string, tagIds: string[]) {
try {
const result = await api.patch(`/photos/${mediaId}/tags`, { tagIds });
@ -137,11 +68,4 @@ export const tagStore = {
return false;
}
},
/**
* Reset store
*/
reset() {
sharedTagStore.clear();
},
};

View file

@ -8,13 +8,22 @@
import { authStore } from '$lib/stores/auth.svelte';
import { photoStore } from '$lib/stores/photos.svelte';
import { albumStore } from '$lib/stores/albums.svelte';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { setContext } from 'svelte';
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { photosStore } from '$lib/data/local-store';
// Live query for shared tags (local-first)
const allTags = useAllSharedTags();
setContext('tags', allTags);
let { children } = $props();
let isDark = $derived(theme.isDark);
@ -81,7 +90,6 @@
await authStore.signOut();
photoStore.reset();
albumStore.reset();
tagStore.reset();
goto('/login');
}
@ -89,7 +97,7 @@
async function handleInputSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
const albums = albumStore.albums.filter((a) => a.name?.toLowerCase().includes(q));
const tags = tagStore.tags.filter((t) => t.name?.toLowerCase().includes(q));
const tags = allTags.value.filter((t) => t.name?.toLowerCase().includes(q));
const results: QuickInputItem[] = [];
for (const album of albums.slice(0, 5)) {
results.push({ id: `album-${album.id}`, title: album.name, subtitle: 'Album' });
@ -111,10 +119,11 @@
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await photosStore.initialize();
await Promise.all([photosStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
photosStore.startSync(() => authStore.getValidToken());
await Promise.all([photoStore.loadStats(), albumStore.loadAlbums(), tagStore.loadTags()]);
tagMutations.startSync(() => authStore.getValidToken());
await Promise.all([photoStore.loadStats(), albumStore.loadAlbums()]);
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('photos')) {
showGuestWelcome = true;
@ -152,7 +161,7 @@
<!-- TagStrip (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 || '#6b7280',

View file

@ -11,7 +11,9 @@
import { images, selectedImage } from '$lib/stores/images';
import { toastStore } from '@manacore/shared-ui';
import { fade, fly } from 'svelte/transition';
import { getImageTags, getAllTags, addTagToImage, removeTagFromImage } from '$lib/api/tags';
import { getImageTags, addTagToImage, removeTagFromImage } from '$lib/api/tags';
import { getContext } from 'svelte';
import type { Tag as SharedTag } from '@manacore/shared-tags';
import {
X,
Info,
@ -32,16 +34,28 @@
let { image, onClose }: Props = $props();
const sharedTags: { value: SharedTag[] } = getContext('tags');
let isArchiving = $state(false);
let isDeleting = $state(false);
let imageTags = $state<Tag[]>([]);
let showInfo = $state(false);
let showTagModal = $state(false);
let showPublishModal = $state(false);
let allTags = $state<Tag[]>([]);
let isLoadingTags = $state(false);
let isPublishing = $state(false);
// Derive allTags from the shared context
let allTags = $derived(
sharedTags.value.map(
(t): Tag => ({
id: t.id,
name: t.name,
color: t.color,
createdAt: t.createdAt,
})
)
);
// Get current image index
const currentIndex = $derived(image ? $images.findIndex((img) => img.id === image?.id) : -1);
@ -157,17 +171,8 @@
}).format(date);
}
async function openTagModal() {
function openTagModal() {
showTagModal = true;
isLoadingTags = true;
try {
allTags = await getAllTags();
} catch (error) {
console.error('Error loading tags:', error);
toastStore.show('Fehler beim Laden der Tags', 'error');
} finally {
isLoadingTags = false;
}
}
function closeTagModal() {
@ -481,13 +486,7 @@
</button>
</div>
{#if isLoadingTags}
<div class="flex items-center justify-center py-8">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
</div>
{:else if allTags.length === 0}
{#if allTags.length === 0}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">Keine Tags verfügbar</p>
{:else}
<div class="max-h-96 space-y-2 overflow-y-auto">

View file

@ -14,8 +14,12 @@
hasMoreExplore,
showExploreFavoritesOnly,
} from '$lib/stores/explore';
import { tags, selectedTags } from '$lib/stores/tags';
import { selectedTags } from '$lib/stores/tags';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { showFavoritesOnly } from '$lib/stores/images';
const allTags: { value: Tag[] } = getContext('tags');
import { searchPublicImages, getPublicImages } from '$lib/api/explore';
import { showKeyboardShortcuts } from '$lib/stores/ui';
import TagPills from '$lib/components/tags/TagPills.svelte';
@ -373,7 +377,7 @@
<span>Favoriten</span>
</button>
{#if $tags.length > 0}
{#if allTags.value.length > 0}
<TagPills />
{:else}
<p class="text-xs text-gray-500 dark:text-gray-400">Keine Tags vorhanden</p>

View file

@ -1,9 +1,10 @@
<script lang="ts">
import { tags, selectedTags } from '$lib/stores/tags';
import type { Database } from '@picture/shared/types';
import { selectedTags } from '$lib/stores/tags';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { Check } from '@manacore/shared-icons';
type Tag = Database['public']['Tables']['tags']['Row'];
const allTags: { value: Tag[] } = getContext('tags');
function toggleTag(tagId: string) {
selectedTags.update((current) => {
@ -26,7 +27,7 @@
</script>
<div class="flex flex-wrap gap-2">
{#each $tags as tag (tag.id)}
{#each allTags.value as tag (tag.id)}
{@const selected = isSelected(tag.id)}
<button
onclick={() => toggleTag(tag.id)}
@ -44,7 +45,7 @@
</button>
{/each}
{#if $tags.length === 0}
{#if allTags.value.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">
Keine Tags vorhanden. Erstelle Tags in der Tag-Verwaltung.
</p>

View file

@ -6,14 +6,16 @@
showTagSubmenu,
hideTagSubmenu,
} from '$lib/stores/contextMenu';
import { tags } from '$lib/stores/tags';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { authStore } from '$lib/stores/auth.svelte';
const allTags: { value: Tag[] } = getContext('tags');
import { addTagToImage, removeTagFromImage, getImageTags } from '$lib/api/tags';
import { archiveImage, unarchiveImage, deleteImage, toggleFavorite } from '$lib/api/images';
import { images } from '$lib/stores/images';
import { archivedImages } from '$lib/stores/archive';
import { toastStore } from '@manacore/shared-ui';
import type { Tag } from '$lib/api/tags';
import {
DownloadSimple,
Link,
@ -322,7 +324,7 @@
onmouseleave={hideTagSubmenu}
role="menu"
>
{#if $tags.length === 0}
{#if allTags.value.length === 0}
<div class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">Keine Tags vorhanden</div>
{:else}
<div class="px-3 pb-2 pt-1">
@ -330,7 +332,7 @@
Tags hinzufügen/entfernen
</p>
</div>
{#each $tags as tag}
{#each allTags.value as tag}
{@const hasTag = imageTags.some((t) => t.id === tag.id)}
<button
onclick={() => {

View file

@ -1,57 +1,16 @@
/**
* Tags Store - Uses shared Tag Store backed by central mana-core-auth
*
* Replaces old Svelte 4 writable stores with createTagStore wrapper.
* Exports writable-compatible stores for backward compatibility with existing consumers.
* Tag Store Local-First via Shared Tag Store
*/
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';
import { browser } from '$app/environment';
import { writable, derived } from 'svelte/store';
import { createTagStore, type TagStore } from '@manacore/shared-stores';
import { authStore } from '$lib/stores/auth.svelte';
import type { Tag } from '@manacore/shared-tags';
import { writable } from 'svelte/store';
// Re-export Tag for backward compatibility with '$lib/api/tags' Tag type
export type { 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 (Svelte 5 runes-based)
const sharedTagStore: TagStore = createTagStore({
authUrl: getAuthUrl(),
getToken: () => authStore.getValidToken(),
});
// Backward-compatible writable stores for existing consumers
// These are kept as writables so $tags and $selectedTags syntax still works
export const tags = writable<Tag[]>([]);
/** UI-only: currently selected tag IDs for filtering (not persisted) */
export const selectedTags = writable<string[]>([]);
export const isLoadingTags = writable<boolean>(false);
/**
* Fetch tags from the shared store and sync to the writable store.
* Call this on mount instead of using the old getAllTags() API function.
*/
export async function fetchAndSyncTags(): Promise<void> {
isLoadingTags.set(true);
try {
await sharedTagStore.fetchTags();
tags.set(sharedTagStore.tags);
} catch (e) {
console.error('Failed to fetch tags:', e);
} finally {
isLoadingTags.set(false);
}
}
/**
* Direct access to the shared tag store for components that want the runes-based API.
*/
export const tagStore = sharedTagStore;

View file

@ -19,7 +19,12 @@
import KeyboardShortcutsModal from '$lib/components/ui/KeyboardShortcutsModal.svelte';
import { theme } from '$lib/stores/theme';
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { tagStore } from '$lib/stores/tags';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { setContext } from 'svelte';
import { pictureOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
@ -32,6 +37,10 @@
// App switcher items
const appItems = getPillAppItems('picture');
// Live query for shared tags (local-first)
const allTags = useAllSharedTags();
setContext('tags', allTags);
let { children } = $props();
// PillNav state
@ -87,12 +96,13 @@
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await pictureStore.initialize();
// Initialize local-first databases (opens IndexedDB, seeds guest data)
await Promise.all([pictureStore.initialize(), tagLocalStore.initialize()]);
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
pictureStore.startSync(() => authStore.getValidToken());
tagMutations.startSync(() => authStore.getValidToken());
}
// Show guest welcome modal on first visit
@ -101,7 +111,7 @@
}
if (authStore.isAuthenticated) {
await Promise.all([userSettings.load(), tagStore.fetchTags()]);
await userSettings.load();
}
// Redirect to start page if on /app and a custom start page is set
@ -313,7 +323,7 @@
<!-- TagStrip (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 || '#6b7280',

View file

@ -9,8 +9,10 @@
showFavoritesOnly,
} from '$lib/stores/images';
import { isUIVisible } from '$lib/stores/ui';
import { tags, selectedTags } from '$lib/stores/tags';
import { imageCollection, imageTagCollection, tagCollection } from '$lib/data/local-store';
import { selectedTags } from '$lib/stores/tags';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { imageCollection, imageTagCollection } from '$lib/data/local-store';
import type { Image } from '$lib/api/images';
import type { LocalImage } from '$lib/data/local-store';
import GalleryGrid from '$lib/components/gallery/GalleryGrid.svelte';
@ -23,6 +25,8 @@
import { Heart } from '@manacore/shared-icons';
import { onMount } from 'svelte';
const allTags: { value: Tag[] } = getContext('tags');
const PAGE_SIZE = 20;
let loadingMore = $state(false);
@ -59,9 +63,7 @@
}
onMount(() => {
loadTags().then(() => {
loadInitialImages();
});
loadInitialImages();
// Setup Intersection Observer for infinite scroll
observer = new IntersectionObserver(
@ -85,22 +87,6 @@
};
});
async function loadTags() {
try {
const localTags = await tagCollection.getAll();
tags.set(
localTags.map((t) => ({
id: t.id,
name: t.name,
color: t.color ?? undefined,
createdAt: t.createdAt ?? new Date().toISOString(),
}))
);
} catch (error) {
console.error('Error loading tags:', error);
}
}
// React to tag and favorites filter changes
$effect(() => {
if ($selectedTags || $showFavoritesOnly !== undefined) {
@ -210,7 +196,7 @@
</button>
<!-- Tags -->
{#if $tags.length > 0}
{#if allTags.value.length > 0}
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Tags:</span

View file

@ -1,12 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { tags, isLoadingTags } from '$lib/stores/tags';
import { getAllTags, createTag, updateTag, deleteTag } from '$lib/api/tags';
import type { Tag } from '$lib/api/tags';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
import { toastStore } from '@manacore/shared-ui';
import { PageHeader } from '@manacore/shared-ui';
import { Plus, Tag as TagIcon, PencilSimple, Trash } from '@manacore/shared-icons';
const allTags: { value: Tag[] } = getContext('tags');
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingTag = $state<Tag | null>(null);
@ -26,32 +27,14 @@
'#14B8A6', // teal
];
onMount(async () => {
await loadTags();
});
async function loadTags() {
isLoadingTags.set(true);
try {
const data = await getAllTags();
tags.set(data);
} catch (error) {
console.error('Error loading tags:', error);
toastStore.show('Fehler beim Laden der Tags', 'error');
} finally {
isLoadingTags.set(false);
}
}
async function handleCreateTag() {
if (!newTagName.trim()) return;
try {
await createTag({
await tagMutations.createTag({
name: newTagName.trim(),
color: newTagColor,
});
await loadTags();
toastStore.show('Tag erfolgreich erstellt', 'success');
newTagName = '';
newTagColor = '#3B82F6';
@ -73,11 +56,10 @@
if (!editingTag || !editTagName.trim()) return;
try {
await updateTag(editingTag.id, {
await tagMutations.updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
});
await loadTags();
toastStore.show('Tag erfolgreich aktualisiert', 'success');
showEditModal = false;
editingTag = null;
@ -91,8 +73,7 @@
if (!confirm('Möchten Sie diesen Tag wirklich löschen?')) return;
try {
await deleteTag(tagId);
await loadTags();
await tagMutations.deleteTag(tagId);
toastStore.show('Tag erfolgreich gelöscht', 'success');
} catch (error) {
console.error('Error deleting tag:', error);
@ -124,13 +105,7 @@
</PageHeader>
<!-- Tags Grid -->
{#if $isLoadingTags}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
</div>
{:else if $tags.length === 0}
{#if allTags.value.length === 0}
<div
class="rounded-3xl border border-gray-200/50 bg-white/80 p-12 text-center backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/80"
>
@ -144,7 +119,7 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each $tags as tag (tag.id)}
{#each allTags.value as tag (tag.id)}
<div
class="group relative rounded-2xl border border-gray-200/50 bg-white/80 p-6 backdrop-blur-xl transition-all hover:shadow-lg dark:border-gray-700/50 dark:bg-gray-900/80"
>

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -3,7 +3,11 @@
import { page } from '$app/stores';
import { PillNavigation, QuickInputBar, TagStrip } from '@manacore/shared-ui';
import type { PillNavItem, QuickInputItem, CreatePreview } from '@manacore/shared-ui';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { plantsApi } from '$lib/api/plants';
@ -14,10 +18,14 @@
} from '$lib/utils/plant-parser';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { setContext } from 'svelte';
import { plantaStore } from '$lib/data/local-store';
let { children } = $props();
const allTags = useAllSharedTags();
setContext('tags', allTags);
let showGuestWelcome = $state(false);
// TagStrip visibility
@ -101,10 +109,11 @@
}
async function handleAuthReady() {
await plantaStore.initialize();
await Promise.all([plantaStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
plantaStore.startSync(() => authStore.getValidToken());
await tagStore.fetchTags();
const getToken = () => authStore.getValidToken();
plantaStore.startSync(getToken);
tagMutations.startSync(getToken);
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('planta')) {
showGuestWelcome = true;
@ -134,7 +143,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',
@ -143,7 +152,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,9 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +16,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
import { browser } from '$app/environment';
import { createTagStore } from '@manacore/shared-stores';
import { auth } 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: () => auth.getValidToken(),
});
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -1,10 +1,15 @@
<script lang="ts">
import { setContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { PillNavigation, QuickInputBar, TagStrip } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { auth } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { theme } from '$lib/stores/theme';
@ -23,6 +28,10 @@
// App switcher items
const appItems = getPillAppItems('presi');
// Shared tag store (local-first)
const allTags = useAllSharedTags();
setContext('tags', allTags);
let { children } = $props();
let isCollapsed = $state(false);
@ -143,12 +152,14 @@
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await presiStore.initialize();
// Initialize local-first databases (opens IndexedDB, seeds guest data)
await Promise.all([presiStore.initialize(), tagLocalStore.initialize()]);
// If authenticated, start syncing to server
if (auth.isAuthenticated) {
presiStore.startSync(() => auth.getValidToken());
const getToken = () => auth.getValidToken();
presiStore.startSync(getToken);
tagMutations.startSync(getToken);
}
// Load decks from IndexedDB (guest seed or synced data)
@ -160,9 +171,8 @@
}
if (auth.isAuthenticated) {
// Load user settings and tags (require auth)
// Load user settings (requires auth)
await userSettings.load();
await tagStore.fetchTags();
}
// Redirect to start page if on root and a custom start page is set
@ -228,7 +238,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',
@ -237,7 +247,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,10 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +17,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
import { browser } from '$app/environment';
import { createTagStore } from '@manacore/shared-stores';
import { authStore } from '$lib/stores';
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, setContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
@ -19,10 +19,17 @@
CreatePreview,
} from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
let { children } = $props();
const allTags = useAllSharedTags();
setContext('tags', allTags);
// App switcher items
const appItems = getPillAppItems('questions');
@ -47,14 +54,15 @@
let showGuestWelcome = $state(false);
async function handleAuthReady() {
await questionsAppStore.initialize();
await Promise.all([questionsAppStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
questionsAppStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
questionsAppStore.startSync(getToken);
tagMutations.startSync(getToken);
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
await collectionsStore.load();
await questionsStore.load();
await tagStore.fetchTags();
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('questions')) {
showGuestWelcome = true;
@ -202,7 +210,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',
@ -211,7 +219,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,9 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +16,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

@ -1,62 +1,11 @@
/**
* Tags Store - Uses shared Tag Store backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
*/
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';
// Re-export Tag for convenience
export type { 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(),
});
// Export the store with a backward-compatible wrapper
export const tagsStore = {
get tags() {
return tagStore.tags;
},
get loading() {
return tagStore.loading;
},
get error() {
return tagStore.error;
},
async fetchTags() {
return tagStore.fetchTags();
},
getById(id: string) {
return tagStore.getById(id);
},
getColor(tagId: string) {
return tagStore.getColor(tagId);
},
async createTag(data: { name: string; color?: string }) {
return tagStore.createTag(data);
},
async updateTag(id: string, data: { name?: string; color?: string }) {
return tagStore.updateTag(id, data);
},
async deleteTag(id: string) {
return tagStore.deleteTag(id);
},
clear() {
tagStore.clear();
},
};
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -8,7 +8,12 @@
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { tagsStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { setContext } from 'svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
@ -27,6 +32,10 @@
// App switcher items
const appItems = getPillAppItems('storage');
// Live query for shared tags (local-first)
const allTags = useAllSharedTags();
setContext('tags', allTags);
let { children } = $props();
let isCollapsed = $state(false);
@ -165,12 +174,13 @@
}
async function handleAuthReady() {
// Initialize local-first database
await storageStore.initialize();
// Initialize local-first databases
await Promise.all([storageStore.initialize(), tagLocalStore.initialize()]);
// If authenticated, start syncing
if (authStore.isAuthenticated) {
storageStore.startSync(() => authStore.getValidToken());
tagMutations.startSync(() => authStore.getValidToken());
}
// Initialize theme
@ -182,8 +192,8 @@
}
if (authStore.isAuthenticated) {
// Load user settings and tags (require auth)
await Promise.all([userSettings.load(), tagsStore.fetchTags()]);
// Load user settings (require auth)
await userSettings.load();
}
// Initialize collapsed state from localStorage
@ -248,7 +258,7 @@
<!-- TagStrip (toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
tags={tagsStore.tags.map((t) => ({
tags={allTags.value.map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#6b7280',

View file

@ -1,9 +1,12 @@
<script lang="ts">
import { tagsStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
import { toastStore, PageHeader } from '@manacore/shared-ui';
import { Plus, Tag as TagIcon, PencilSimple, Trash } from '@manacore/shared-icons';
const allTags: { value: Tag[] } = getContext('tags');
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingTag = $state<{ id: string; name: string; color: string } | null>(null);
@ -23,17 +26,11 @@
'#14B8A6',
];
onMount(async () => {
if (tagsStore.tags.length === 0) {
await tagsStore.fetchTags();
}
});
async function handleCreateTag() {
if (!newTagName.trim()) return;
try {
await tagsStore.createTag({
await tagMutations.createTag({
name: newTagName.trim(),
color: newTagColor,
});
@ -58,7 +55,7 @@
if (!editingTag || !editTagName.trim()) return;
try {
await tagsStore.updateTag(editingTag.id, {
await tagMutations.updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
});
@ -75,7 +72,7 @@
if (!confirm('Möchten Sie diesen Tag wirklich löschen?')) return;
try {
await tagsStore.deleteTag(tagId);
await tagMutations.deleteTag(tagId);
toastStore.show('Tag erfolgreich gelöscht', 'success');
} catch (error) {
console.error('Error deleting tag:', error);
@ -107,13 +104,7 @@
</PageHeader>
<!-- Tags Grid -->
{#if tagsStore.loading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
</div>
{:else if tagsStore.tags.length === 0}
{#if allTags.value.length === 0}
<div
class="rounded-3xl border border-gray-200/50 bg-white/80 p-12 text-center backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/80"
>
@ -127,7 +118,7 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each tagsStore.tags as tag (tag.id)}
{#each allTags.value as tag (tag.id)}
<div
class="group relative rounded-2xl border border-gray-200/50 bg-white/80 p-6 backdrop-blur-xl transition-all hover:shadow-lg dark:border-gray-700/50 dark:bg-gray-900/80"
>

View file

@ -1,5 +0,0 @@
export { apiClient } from './client';
export * from './projects';
export * from './tasks';
export * from './labels';
export * from './reminders';

View file

@ -1,76 +0,0 @@
/**
* Labels API - Uses central Tags API from mana-core-auth
*
* This module wraps the central Tags API to provide backward-compatible
* "labels" interface for the Todo app. Tags and Labels are now unified
* across all Manacore apps.
*/
import { browser } from '$app/environment';
import {
createTagsClient,
type Tag,
type CreateTagInput,
type UpdateTagInput,
} from '@manacore/shared-tags';
import { authStore } from '$lib/stores/auth.svelte';
// Re-export Tag as Label for backward compatibility
export type Label = Tag;
// Get auth URL dynamically at runtime
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';
}
// Lazy-initialized client
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
function getTagsClient() {
if (!browser) return null;
if (!_tagsClient) {
_tagsClient = createTagsClient({
authUrl: getAuthUrl(),
getToken: async () => {
const token = await authStore.getAccessToken();
return token || '';
},
});
}
return _tagsClient;
}
export async function getLabels(): Promise<Label[]> {
const client = getTagsClient();
if (!client) return [];
return client.getAll();
}
export async function createLabel(data: CreateTagInput): Promise<Label> {
const client = getTagsClient();
if (!client) throw new Error('Tags client not available');
return client.create(data);
}
export async function updateLabel(id: string, data: UpdateTagInput): Promise<Label> {
const client = getTagsClient();
if (!client) throw new Error('Tags client not available');
return client.update(id, data);
}
export async function deleteLabel(id: string): Promise<void> {
const client = getTagsClient();
if (!client) throw new Error('Tags client not available');
await client.delete(id);
}
export async function createDefaultLabels(): Promise<Label[]> {
const client = getTagsClient();
if (!client) return [];
return client.createDefaults();
}

View file

@ -1,62 +0,0 @@
import { apiClient } from './client';
import type { Project } from '@todo/shared';
interface CreateProjectDto {
name: string;
description?: string;
color?: string;
icon?: string;
}
interface UpdateProjectDto {
name?: string;
description?: string;
color?: string;
icon?: string;
isArchived?: boolean;
}
interface ReorderProjectsDto {
projectIds: string[];
}
interface ProjectsResponse {
projects: Project[];
}
interface ProjectResponse {
project: Project;
}
export async function getProjects(): Promise<Project[]> {
const response = await apiClient.get<ProjectsResponse>('/api/v1/projects');
return response.projects;
}
export async function getProject(id: string): Promise<Project> {
const response = await apiClient.get<ProjectResponse>(`/api/v1/projects/${id}`);
return response.project;
}
export async function createProject(data: CreateProjectDto): Promise<Project> {
const response = await apiClient.post<ProjectResponse>('/api/v1/projects', data);
return response.project;
}
export async function updateProject(id: string, data: UpdateProjectDto): Promise<Project> {
const response = await apiClient.put<ProjectResponse>(`/api/v1/projects/${id}`, data);
return response.project;
}
export async function deleteProject(id: string): Promise<void> {
await apiClient.delete(`/api/v1/projects/${id}`);
}
export async function archiveProject(id: string): Promise<Project> {
const response = await apiClient.post<ProjectResponse>(`/api/v1/projects/${id}/archive`);
return response.project;
}
export async function reorderProjects(projectIds: string[]): Promise<void> {
await apiClient.put('/api/v1/projects/reorder', { projectIds } as ReorderProjectsDto);
}

View file

@ -1,32 +0,0 @@
import { apiClient } from './client';
import type { Reminder, ReminderType } from '@todo/shared';
interface CreateReminderDto {
minutesBefore: number;
type?: ReminderType;
}
interface RemindersResponse {
reminders: Reminder[];
}
interface ReminderResponse {
reminder: Reminder;
}
export async function getReminders(taskId: string): Promise<Reminder[]> {
const response = await apiClient.get<RemindersResponse>(`/api/v1/tasks/${taskId}/reminders`);
return response.reminders;
}
export async function createReminder(taskId: string, data: CreateReminderDto): Promise<Reminder> {
const response = await apiClient.post<ReminderResponse>(
`/api/v1/tasks/${taskId}/reminders`,
data
);
return response.reminder;
}
export async function deleteReminder(id: string): Promise<void> {
await apiClient.delete(`/api/v1/reminders/${id}`);
}

View file

@ -1,8 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects, getProjectById } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import { format, addDays } from 'date-fns';
@ -33,7 +37,7 @@
// Derived values
let currentPriority = $derived(PRIORITY_OPTIONS.find((p) => p.value === selectedPriority)!);
let selectedProject = $derived(
selectedProjectId ? projectsStore.getById(selectedProjectId) : undefined
selectedProjectId ? getProjectById(projectsCtx.value, selectedProjectId) : undefined
);
let dateLabel = $derived(() => {
const today = new Date();
@ -282,7 +286,7 @@
<span class="project-dot" style="background-color: #6b7280"></span>
Kein Projekt
</button>
{#each projectsStore.activeProjects as project}
{#each getActiveProjects(projectsCtx.value) as project}
<button
type="button"
class="dropdown-item"

View file

@ -1,11 +1,13 @@
<script lang="ts">
import { labelsStore } from '$lib/stores/labels.svelte';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { viewStore } from '$lib/stores/view.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { DotsThree, Plus, X } from '@manacore/shared-icons';
import TagStripModal from './TagStripModal.svelte';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
interface Props {
/** Whether the filter strip below is visible (affects vertical position) */
filterStripVisible?: boolean;
@ -39,16 +41,10 @@
}
const sortedTags = $derived.by(() => {
return [...labelsStore.labels].sort((a, b) => a.name.localeCompare(b.name, 'de'));
return [...tagsCtx.value].sort((a, b) => a.name.localeCompare(b.name, 'de'));
});
const hasTags = $derived(labelsStore.labels.length > 0);
onMount(async () => {
if (labelsStore.labels.length === 0) {
await labelsStore.fetchLabels();
}
});
const hasTags = $derived(tagsCtx.value.length > 0);
</script>
<div class="tag-strip-wrapper" class:above-filter-strip={filterStripVisible}>
@ -71,9 +67,7 @@
<span class="tag-name">Alle Tags</span>
</button>
{#if labelsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if !hasTags}
{#if !hasTags}
<button class="empty-state glass-tag" onclick={() => goto('/tags')}>
<span>Keine Tags vorhanden</span>
<span class="add-hint">+ Erstellen</span>

View file

@ -1,8 +1,11 @@
<script lang="ts">
import { labelsStore } from '$lib/stores/labels.svelte';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { tagMutations } from '@manacore/shared-stores';
import { Plus, X, Check, Pencil, Trash, MagnifyingGlass } from '@manacore/shared-icons';
import { TagColorPicker, focusTrap } from '@manacore/shared-ui';
import type { Label } from '$lib/api/labels';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
interface Props {
visible: boolean;
@ -21,14 +24,14 @@
let isCreatingTag = $state(false);
// Edit tag state
let editingTag = $state<Label | null>(null);
let editingTag = $state<Tag | null>(null);
let editTagName = $state('');
let editTagColor = $state('#8b5cf6');
let isSavingTag = $state(false);
// Filtered and sorted tags
const sortedTags = $derived.by(() => {
const tags = [...labelsStore.labels].sort((a, b) => a.name.localeCompare(b.name, 'de'));
const tags = [...tagsCtx.value].sort((a, b) => a.name.localeCompare(b.name, 'de'));
if (!searchQuery.trim()) return tags;
const query = searchQuery.toLowerCase();
return tags.filter((t) => t.name.toLowerCase().includes(query));
@ -52,7 +55,7 @@
isCreatingTag = true;
try {
await labelsStore.createLabel({
await tagMutations.createTag({
name: newTagName.trim(),
color: newTagColor,
});
@ -74,7 +77,7 @@
}
// ==================== EDIT TAG ====================
function openEditTag(tag: Label) {
function openEditTag(tag: Tag) {
editingTag = tag;
editTagName = tag.name;
editTagColor = tag.color;
@ -91,7 +94,7 @@
isSavingTag = true;
try {
await labelsStore.updateLabel(editingTag.id, {
await tagMutations.updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
});
@ -106,7 +109,7 @@
if (!editingTag) return;
try {
await labelsStore.deleteLabel(editingTag.id);
await tagMutations.deleteTag(editingTag.id);
closeEditTag();
} catch (e) {
console.error('Failed to delete tag:', e);
@ -164,9 +167,7 @@
<!-- Content -->
<div class="modal-content">
{#if labelsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if labelsStore.labels.length === 0 && !showNewTagForm}
{#if tagsCtx.value.length === 0 && !showNewTagForm}
<div class="empty-state">
<p>Keine Tags vorhanden</p>
<button class="create-btn" onclick={openNewTagForm}>

View file

@ -9,7 +9,11 @@
} from '@todo/shared';
import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { projectsStore } from '$lib/stores/projects.svelte';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { contactsStore } from '$lib/stores/contacts.svelte';
import { format } from 'date-fns';
import SubtaskList from './SubtaskList.svelte';
@ -288,7 +292,7 @@
<label class="form-label" for="task-project">Projekt</label>
<select id="task-project" class="form-select" bind:value={projectId}>
<option value={null}>Kein Projekt</option>
{#each projectsStore.activeProjects as project}
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>
{project.name}
</option>

View file

@ -1,8 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { TaskPriority } from '@todo/shared';
import { projectsStore } from '$lib/stores/projects.svelte';
import { labelsStore } from '$lib/stores/labels.svelte';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import type { Tag } from '@manacore/shared-tags';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
import type { SortBy, SortOrder } from '$lib/stores/view.svelte';
import { X, DotsThree } from '@manacore/shared-icons';
@ -215,7 +221,7 @@
onchange={(e) => onProjectChange(e.currentTarget.value || null)}
>
<option value="">Alle Projekte</option>
{#each projectsStore.activeProjects as project}
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
@ -325,7 +331,7 @@
onchange={(e) => onProjectChange(e.currentTarget.value || null)}
>
<option value="">Alle Projekte</option>
{#each projectsStore.activeProjects as project}
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
@ -346,7 +352,7 @@
{#if selectedLabelIds.length > 0}
<div class="flex items-center gap-1">
{#each selectedLabelIds.slice(0, 3) as labelId}
{@const label = labelsStore.labels.find((l) => l.id === labelId)}
{@const label = tagsCtx.value.find((l) => l.id === labelId)}
{#if label}
<div
class="w-3 h-3 rounded-full ring-2 ring-background"
@ -385,11 +391,11 @@
<div
class="absolute top-full left-0 mt-2 z-50 min-w-[220px] bg-popover border border-border rounded-xl shadow-lg p-2 animate-in fade-in slide-in-from-top-2 duration-150"
>
{#if labelsStore.labels.length === 0}
{#if tagsCtx.value.length === 0}
<p class="text-sm text-muted-foreground p-3 text-center">Keine Tags vorhanden</p>
{:else}
<div class="max-h-[200px] overflow-y-auto">
{#each labelsStore.labels as label}
{#each tagsCtx.value as label}
<button
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-muted/50 transition-colors"
onclick={() => toggleLabel(label.id)}

View file

@ -11,7 +11,11 @@
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import { projectsStore } from '$lib/stores/projects.svelte';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects, getProjectColor } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { contactsStore } from '$lib/stores/contacts.svelte';
import { ContactAvatar, ContactSelector } from '@manacore/shared-ui';
import SubtaskList from './SubtaskList.svelte';
@ -298,7 +302,7 @@
// Get project color
let projectColor = $derived(() => {
if (!task.projectId) return null;
return projectsStore.getColor(task.projectId);
return getProjectColor(projectsCtx.value, task.projectId);
});
// Subtasks progress
@ -566,7 +570,7 @@
<label class="form-label" for="task-project-{task.id}">Projekt</label>
<select id="task-project-{task.id}" class="form-select" bind:value={projectId}>
<option value={null}>Kein Projekt</option>
{#each projectsStore.activeProjects as project}
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>

View file

@ -2,8 +2,12 @@
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { Task, UpdateTaskInput } from '@todo/shared';
import TaskItem from './TaskItem.svelte';
import { getContext } from 'svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
// Context menu state
@ -64,7 +68,7 @@
];
// Add project move options if there are projects
const projects = projectsStore.activeProjects;
const projects = getActiveProjects(projectsCtx.value);
if (projects.length > 0) {
items.push({ id: 'divider-2', label: '', type: 'divider' });
items.push({

View file

@ -2,8 +2,11 @@
import { goto } from '$app/navigation';
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import { projectsStore } from '$lib/stores/projects.svelte';
import { labelsStore } from '$lib/stores/labels.svelte';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
import { PillToolbarButton, PillToolbarDivider, PillViewSwitcher } from '@manacore/shared-ui';
@ -144,7 +147,7 @@
onchange={(e) => (selectedProjectFilter = e.currentTarget.value || null)}
>
<option value="">Alle Projekte</option>
{#each projectsStore.activeProjects as project}
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>

View file

@ -1,5 +1,8 @@
<script lang="ts">
import { labelsStore } from '$lib/stores/labels.svelte';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
interface Props {
selectedIds: string[];
@ -37,7 +40,7 @@
{:else}
<div class="selected-tags">
{#each selectedIds.slice(0, 3) as tagId}
{@const tag = labelsStore.getById(tagId)}
{@const tag = tagsCtx.value.find((t) => t.id === tagId)}
{#if tag}
<span class="tag-chip" style="--tag-color: {tag.color}">
{tag.name}
@ -56,7 +59,7 @@
{#if showDropdown}
<div class="tag-dropdown" onclick={(e) => e.stopPropagation()} role="listbox">
{#each labelsStore.labels as tag}
{#each tagsCtx.value as tag}
<button
type="button"
class="tag-option"
@ -79,7 +82,7 @@
{/if}
</button>
{/each}
{#if labelsStore.labels.length === 0}
{#if tagsCtx.value.length === 0}
<div class="no-tags">Keine Tags vorhanden</div>
{/if}
</div>

View file

@ -0,0 +1,154 @@
/**
* Reactive Queries & Pure Filter Helpers for Todo
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
taskCollection,
projectCollection,
type LocalTask,
type LocalProject,
} from './local-store';
import type { Task, Project } from '@todo/shared';
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
// ─── Type Converters ───────────────────────────────────────
export function toTask(local: LocalTask): Task {
return {
id: local.id,
projectId: local.projectId,
userId: local.userId ?? 'guest',
title: local.title,
description: local.description,
dueDate: local.dueDate,
scheduledDate: local.scheduledDate,
scheduledStartTime: local.scheduledStartTime,
estimatedDuration: local.estimatedDuration,
priority: local.priority,
status: local.isCompleted ? 'completed' : 'pending',
isCompleted: local.isCompleted,
completedAt: local.completedAt,
order: local.order,
recurrenceRule: local.recurrenceRule,
subtasks: local.subtasks ?? null,
metadata: local.metadata as Task['metadata'],
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toProject(local: LocalProject): Project {
return {
id: local.id,
userId: local.userId ?? 'guest',
name: local.name,
color: local.color,
icon: local.icon,
order: local.order,
isArchived: local.isArchived,
isDefault: local.isDefault,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All tasks, sorted by order. Auto-updates on any change. */
export function useAllTasks() {
return useLiveQueryWithDefault(async () => {
const locals = await taskCollection.getAll(undefined, {
sortBy: 'order',
sortDirection: 'asc',
});
return locals.map(toTask);
}, [] as Task[]);
}
/** All projects, sorted by order. Auto-updates on any change. */
export function useAllProjects() {
return useLiveQueryWithDefault(async () => {
const locals = await projectCollection.getAll(undefined, {
sortBy: 'order',
sortDirection: 'asc',
});
return locals.map(toProject);
}, [] as Project[]);
}
// ─── Pure Filter Functions (for $derived) ──────────────────
export function filterIncomplete(tasks: Task[]): Task[] {
return tasks.filter((t) => !t.isCompleted);
}
export function filterCompleted(tasks: Task[]): Task[] {
return tasks.filter((t) => t.isCompleted);
}
export function filterOverdue(tasks: Task[]): Task[] {
return tasks.filter((t) => {
if (!t.dueDate || t.isCompleted) return false;
const dueDate = new Date(t.dueDate);
return isPast(startOfDay(dueDate)) && !isToday(dueDate);
});
}
export function filterToday(tasks: Task[]): Task[] {
const today = startOfDay(new Date());
return tasks.filter((t) => {
if (t.isCompleted) return false;
if (!t.dueDate) return true;
const taskDate = startOfDay(new Date(t.dueDate));
return taskDate.getTime() === today.getTime();
});
}
export function filterUpcoming(tasks: Task[]): Task[] {
const today = startOfDay(new Date());
const weekFromNow = addDays(today, 7);
return tasks.filter((t) => {
if (!t.dueDate || t.isCompleted) return false;
const dueDate = new Date(t.dueDate);
return isFuture(dueDate) && dueDate <= weekFromNow;
});
}
export function filterByProject(tasks: Task[], projectId: string | null): Task[] {
if (projectId === null) {
return tasks.filter((t) => !t.projectId);
}
return tasks.filter((t) => t.projectId === projectId);
}
export function filterByLabel(tasks: Task[], labelId: string): Task[] {
return tasks.filter((t) => t.labels?.some((l) => l.id === labelId));
}
// ─── Pure Project Helpers ──────────────────────────────────
export function getActiveProjects(projects: Project[]): Project[] {
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
}
export function getArchivedProjects(projects: Project[]): Project[] {
return projects.filter((p) => p.isArchived);
}
export function getInboxProject(projects: Project[]): Project | undefined {
return projects.find((p) => p.isDefault);
}
export function getProjectById(projects: Project[], id: string): Project | undefined {
return projects.find((p) => p.id === id);
}
export function getProjectColor(projects: Project[], projectId: string): string {
const project = projects.find((p) => p.id === projectId);
return project?.color || '#6b7280';
}

View file

@ -1,6 +1,5 @@
export { authStore } from './auth.svelte';
export { projectsStore } from './projects.svelte';
export { tasksStore } from './tasks.svelte';
export { labelsStore } from './labels.svelte';
export { viewStore } from './view.svelte';
export type { ViewType, SortBy, SortOrder } from './view.svelte';

View file

@ -1,62 +0,0 @@
/**
* Labels Store - Uses shared Tag Store backed by central mana-core-auth
*/
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';
// 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 = {
get labels() {
return tagStore.tags;
},
get loading() {
return tagStore.loading;
},
get error() {
return tagStore.error;
},
async fetchLabels() {
return tagStore.fetchTags();
},
getById(id: string) {
return tagStore.getById(id);
},
getColor(labelId: string) {
return tagStore.getColor(labelId);
},
async createLabel(data: { name: string; color?: string }) {
return tagStore.createTag(data);
},
async updateLabel(id: string, data: { name?: string; color?: string }) {
return tagStore.updateTag(id, data);
},
async deleteLabel(id: string) {
return tagStore.deleteTag(id);
},
clear() {
tagStore.clear();
},
};

View file

@ -1,112 +1,44 @@
/**
* Projects Store Local-First with Dexie.js
* Projects Store Mutation-Only Service
*
* All reads and writes go to IndexedDB first.
* Same public API as before so components don't need changes.
* All reads are handled by useLiveQuery() hooks in task-queries.ts.
* This store only provides write operations (create, update, delete, etc.).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import type { Project } from '@todo/shared';
import { projectCollection, type LocalProject } from '$lib/data/local-store';
import { toProject } from '$lib/data/task-queries';
import { TodoEvents } from '@manacore/shared-utils/analytics';
// State
let projects = $state<Project[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalProject (IndexedDB) to the shared Project type. */
function toProject(local: LocalProject): Project {
return {
id: local.id,
userId: local.userId ?? 'guest',
name: local.name,
color: local.color,
icon: local.icon,
order: local.order,
isArchived: local.isArchived,
isDefault: local.isDefault,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export const projectsStore = {
get projects() {
return projects;
},
get loading() {
return loading;
},
get error() {
return error;
},
get inboxProject() {
return projects.find((p) => p.isDefault);
},
get activeProjects() {
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
},
get archivedProjects() {
return projects.filter((p) => p.isArchived);
},
/**
* Load projects from IndexedDB.
*/
async fetchProjects() {
loading = true;
error = null;
try {
const localProjects = await projectCollection.getAll(undefined, {
sortBy: 'order',
sortDirection: 'asc',
});
projects = localProjects.map(toProject);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch projects';
console.error('Failed to fetch projects:', e);
} finally {
loading = false;
}
},
getById(id: string): Project | undefined {
return projects.find((p) => p.id === id);
},
getColor(projectId: string): string {
const project = projects.find((p) => p.id === projectId);
return project?.color || '#6b7280';
},
async createProject(data: { name: string; description?: string; color?: string; icon?: string }) {
loading = true;
error = null;
try {
const count = await projectCollection.count();
const newLocal: LocalProject = {
id: crypto.randomUUID(),
name: data.name,
color: data.color ?? '#6b7280',
icon: data.icon ?? null,
order: projects.length,
order: count,
isArchived: false,
isDefault: false,
};
const inserted = await projectCollection.insert(newLocal);
const newProject = toProject(inserted);
projects = [...projects, newProject];
TodoEvents.projectCreated();
return newProject;
return toProject(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create project';
console.error('Failed to create project:', e);
throw e;
} finally {
loading = false;
}
},
@ -118,9 +50,7 @@ export const projectsStore = {
try {
const updated = await projectCollection.update(id, data as Partial<LocalProject>);
if (updated) {
const updatedProject = toProject(updated);
projects = projects.map((p) => (p.id === id ? updatedProject : p));
return updatedProject;
return toProject(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update project';
@ -133,7 +63,6 @@ export const projectsStore = {
error = null;
try {
await projectCollection.delete(id);
projects = projects.filter((p) => p.id !== id);
TodoEvents.projectDeleted();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete project';
@ -149,9 +78,7 @@ export const projectsStore = {
isArchived: true,
} as Partial<LocalProject>);
if (updated) {
const archivedProject = toProject(updated);
projects = projects.map((p) => (p.id === id ? archivedProject : p));
return archivedProject;
return toProject(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to archive project';
@ -163,11 +90,6 @@ export const projectsStore = {
async reorderProjects(projectIds: string[]) {
error = null;
try {
projects = projects.map((p) => {
const newOrder = projectIds.indexOf(p.id);
return newOrder !== -1 ? { ...p, order: newOrder } : p;
});
for (let i = 0; i < projectIds.length; i++) {
await projectCollection.update(projectIds[i], { order: i } as Partial<LocalProject>);
}
@ -178,16 +100,6 @@ export const projectsStore = {
}
},
clear() {
projects = [];
loading = false;
error = null;
},
isGuestInbox(_id: string) {
return false;
},
get guestInboxId() {
return 'personal-project';
},

View file

@ -1,236 +1,23 @@
/**
* Tasks Store Local-First with Dexie.js
* Tasks Store Mutation-Only Service
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* All reads are handled by useLiveQuery() hooks in task-queries.ts.
* This store only provides write operations (create, update, delete, etc.).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import type { Task, TaskPriority, TaskStatus, Subtask } from '@todo/shared';
import { taskCollection, type LocalTask } from '$lib/data/local-store';
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
import { toTask } from '$lib/data/task-queries';
import { TodoEvents } from '@manacore/shared-utils/analytics';
// State — populated from IndexedDB
let tasks = $state<Task[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalTask (IndexedDB record) to the shared Task type. */
function toTask(local: LocalTask): Task {
return {
id: local.id,
projectId: local.projectId,
userId: local.userId ?? 'guest',
title: local.title,
description: local.description,
dueDate: local.dueDate,
scheduledDate: local.scheduledDate,
scheduledStartTime: local.scheduledStartTime,
estimatedDuration: local.estimatedDuration,
priority: local.priority,
status: local.isCompleted ? 'completed' : 'pending',
isCompleted: local.isCompleted,
completedAt: local.completedAt,
order: local.order,
recurrenceRule: local.recurrenceRule,
subtasks: local.subtasks ?? null,
metadata: local.metadata as Task['metadata'],
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load tasks from IndexedDB into the reactive state. */
async function refreshTasks(filter?: Partial<LocalTask>) {
const localTasks = await taskCollection.getAll(filter, { sortBy: 'order', sortDirection: 'asc' });
tasks = localTasks.map(toTask);
}
export const tasksStore = {
// Getters
get tasks() {
return tasks;
},
get loading() {
return loading;
},
get error() {
return error;
},
get incompleteTasks() {
return tasks.filter((t) => !t.isCompleted);
},
get completedTasks() {
return tasks.filter((t) => t.isCompleted);
},
/**
* Fetch tasks with optional filters reads from IndexedDB.
*/
async fetchTasks(
query: {
projectId?: string;
labelId?: string;
priority?: TaskPriority;
status?: TaskStatus;
dueBefore?: string;
dueAfter?: string;
isCompleted?: boolean;
search?: string;
} = {}
) {
loading = true;
error = null;
try {
const filter: Partial<LocalTask> = {};
if (query.projectId) filter.projectId = query.projectId;
if (query.priority) filter.priority = query.priority;
if (query.isCompleted !== undefined) filter.isCompleted = query.isCompleted;
let localTasks = await taskCollection.getAll(
Object.keys(filter).length > 0 ? filter : undefined,
{ sortBy: 'order', sortDirection: 'asc' }
);
// Client-side search filter
if (query.search) {
const search = query.search.toLowerCase();
localTasks = localTasks.filter(
(t) =>
t.title.toLowerCase().includes(search) || t.description?.toLowerCase().includes(search)
);
}
tasks = localTasks.map(toTask);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch tasks';
console.error('Failed to fetch tasks:', e);
} finally {
loading = false;
}
},
async fetchInboxTasks() {
loading = true;
error = null;
try {
const localTasks = await taskCollection.getAll(undefined, {
sortBy: 'order',
sortDirection: 'asc',
});
// Inbox = tasks without projectId or with null projectId
tasks = localTasks.filter((t) => !t.projectId).map(toTask);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch inbox tasks';
} finally {
loading = false;
}
},
async fetchTodayTasks() {
loading = true;
error = null;
try {
const localTasks = await taskCollection.getAll(
{ isCompleted: false },
{ sortBy: 'order', sortDirection: 'asc' }
);
const today = startOfDay(new Date());
tasks = localTasks
.filter((t) => {
if (!t.dueDate) return false;
const d = new Date(t.dueDate);
return isToday(d) || (isPast(startOfDay(d)) && !isToday(d));
})
.map(toTask);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch today tasks';
} finally {
loading = false;
}
},
async fetchUpcomingTasks() {
loading = true;
error = null;
try {
const localTasks = await taskCollection.getAll(
{ isCompleted: false },
{ sortBy: 'dueDate', sortDirection: 'asc' }
);
const today = startOfDay(new Date());
const weekFromNow = addDays(today, 7);
tasks = localTasks
.filter((t) => {
if (!t.dueDate) return false;
const d = new Date(t.dueDate);
return isFuture(d) && d <= weekFromNow;
})
.map(toTask);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch upcoming tasks';
} finally {
loading = false;
}
},
async fetchAllTasks() {
loading = true;
error = null;
try {
await refreshTasks();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch all tasks';
} finally {
loading = false;
}
},
getTasksByProject(projectId: string | null): Task[] {
if (projectId === null) {
return tasks.filter((t) => !t.projectId);
}
return tasks.filter((t) => t.projectId === projectId);
},
getTasksByLabel(labelId: string): Task[] {
return tasks.filter((t) => t.labels?.some((l) => l.id === labelId));
},
get overdueTasks(): Task[] {
return tasks.filter((t) => {
if (!t.dueDate || t.isCompleted) return false;
const dueDate = new Date(t.dueDate);
return isPast(startOfDay(dueDate)) && !isToday(dueDate);
});
},
get todayTasks(): Task[] {
const today = startOfDay(new Date());
return tasks.filter((t) => {
if (t.isCompleted) return false;
if (!t.dueDate) return true;
const taskDate = startOfDay(new Date(t.dueDate));
return taskDate.getTime() === today.getTime();
});
},
get upcomingTasks(): Task[] {
const today = startOfDay(new Date());
const weekFromNow = addDays(today, 7);
return tasks.filter((t) => {
if (!t.dueDate || t.isCompleted) return false;
const dueDate = new Date(t.dueDate);
return isFuture(dueDate) && dueDate <= weekFromNow;
});
},
/**
* Create a new task writes to IndexedDB instantly.
*/
async createTask(data: {
title: string;
description?: string;
@ -243,6 +30,7 @@ export const tasksStore = {
}) {
error = null;
try {
const count = await taskCollection.count();
const newLocal: LocalTask = {
id: crypto.randomUUID(),
title: data.title,
@ -251,16 +39,14 @@ export const tasksStore = {
priority: data.priority ?? 'medium',
isCompleted: false,
dueDate: data.dueDate ?? null,
order: tasks.length,
order: count,
recurrenceRule: data.recurrenceRule ?? null,
subtasks: data.subtasks,
};
const inserted = await taskCollection.insert(newLocal);
const newTask = toTask(inserted);
tasks = [...tasks, newTask];
TodoEvents.taskCreated(!!data.dueDate);
return newTask;
return toTask(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create task';
console.error('Failed to create task:', e);
@ -268,9 +54,6 @@ export const tasksStore = {
}
},
/**
* Update a task writes to IndexedDB instantly.
*/
async updateTask(
id: string,
data: {
@ -296,9 +79,7 @@ export const tasksStore = {
try {
const updated = await taskCollection.update(id, data as Partial<LocalTask>);
if (updated) {
const updatedTask = toTask(updated);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
return updatedTask;
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update task';
@ -307,9 +88,6 @@ export const tasksStore = {
}
},
/**
* Optimistic update for drag-and-drop. Instant local write.
*/
async updateTaskOptimistic(
id: string,
data: {
@ -317,10 +95,6 @@ export const tasksStore = {
isCompleted?: boolean;
}
) {
// Immediate local state update
tasks = tasks.map((t) => (t.id === id ? { ...t, ...data } : t));
// Persist to IndexedDB
const updateData: Partial<LocalTask> = {};
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
if (data.isCompleted !== undefined) {
@ -335,7 +109,6 @@ export const tasksStore = {
error = null;
try {
await taskCollection.delete(id);
tasks = tasks.filter((t) => t.id !== id);
TodoEvents.taskDeleted();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete task';
@ -352,10 +125,8 @@ export const tasksStore = {
completedAt: new Date().toISOString(),
} as Partial<LocalTask>);
if (updated) {
const completedTask = toTask(updated);
tasks = tasks.map((t) => (t.id === id ? completedTask : t));
TodoEvents.taskCompleted();
return completedTask;
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to complete task';
@ -372,10 +143,8 @@ export const tasksStore = {
completedAt: null,
} as Partial<LocalTask>);
if (updated) {
const uncompletedTask = toTask(updated);
tasks = tasks.map((t) => (t.id === id ? uncompletedTask : t));
TodoEvents.taskUncompleted();
return uncompletedTask;
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to uncomplete task';
@ -389,9 +158,7 @@ export const tasksStore = {
try {
const updated = await taskCollection.update(id, { projectId } as Partial<LocalTask>);
if (updated) {
const movedTask = toTask(updated);
tasks = tasks.map((t) => (t.id === id ? movedTask : t));
return movedTask;
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to move task';
@ -401,17 +168,13 @@ export const tasksStore = {
},
async updateLabels(id: string, labelIds: string[]) {
// Labels are stored via the central tag system, not locally.
// For now, update the task metadata to track label associations.
error = null;
try {
const updated = await taskCollection.update(id, {
metadata: { labelIds },
} as Partial<LocalTask>);
if (updated) {
const updatedTask = toTask(updated);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
return updatedTask;
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update labels';
@ -425,9 +188,7 @@ export const tasksStore = {
try {
const updated = await taskCollection.update(id, { subtasks } as Partial<LocalTask>);
if (updated) {
const updatedTask = toTask(updated);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
return updatedTask;
return toTask(updated);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update subtasks';
@ -439,13 +200,6 @@ export const tasksStore = {
async reorderTasks(taskIds: string[]) {
error = null;
try {
// Update order in local state immediately
tasks = tasks.map((t) => {
const newOrder = taskIds.indexOf(t.id);
return newOrder !== -1 ? { ...t, order: newOrder } : t;
});
// Persist each order change to IndexedDB
for (let i = 0; i < taskIds.length; i++) {
await taskCollection.update(taskIds[i], { order: i } as Partial<LocalTask>);
}
@ -455,15 +209,6 @@ export const tasksStore = {
}
},
clear() {
tasks = [];
loading = false;
error = null;
},
/**
* No longer relevant all tasks are local and editable.
*/
isDemoTask(_taskId: string) {
return false;
},

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { setContext } from 'svelte';
import { locale } from 'svelte-i18n';
import {
PillNavigation,
@ -24,8 +25,12 @@
import { userSettings } from '$lib/stores/user-settings.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import { labelsStore } from '$lib/stores/labels.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { theme } from '$lib/stores/theme';
import TaskFilters from '$lib/components/TaskFilters.svelte';
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
@ -39,16 +44,29 @@
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { getTasks } from '$lib/api/tasks';
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
import { todoOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { TodoEvents } from '@manacore/shared-utils/analytics';
import { todoStore } from '$lib/data/local-store';
import { todoStore, taskCollection } from '$lib/data/local-store';
import { useAllTasks, useAllProjects, getActiveProjects } from '$lib/data/task-queries';
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allTasks = useAllTasks();
const allProjects = useAllProjects();
const allTags = useAllSharedTags();
// Provide data to child components via Svelte context
setContext('projects', allProjects);
setContext('tasks', allTasks);
setContext('tags', allTags);
// Derived active projects for UI
let activeProjects = $derived(getActiveProjects(allProjects.value));
// Guest welcome modal state
let showGuestWelcome = $state(false);
@ -71,13 +89,18 @@
let { children } = $props();
// QuickInputBar search - search tasks
// QuickInputBar search - search tasks locally
async function handleSearch(query: string): Promise<QuickInputItem[]> {
if (!query.trim()) return [];
try {
const tasks = await getTasks({ search: query });
return tasks.slice(0, 10).map((task) => ({
const q = query.toLowerCase();
return allTasks.value
.filter(
(task) =>
task.title.toLowerCase().includes(q) || task.description?.toLowerCase().includes(q)
)
.slice(0, 10)
.map((task) => ({
id: task.id,
title: task.title,
subtitle: task.isCompleted
@ -86,9 +109,6 @@
? new Date(task.dueDate).toLocaleDateString('de-DE')
: 'Kein Datum',
}));
} catch {
return [];
}
}
function handleSelect(item: QuickInputItem) {
@ -114,7 +134,7 @@
try {
const parsed = parseTaskInput(query);
const resolved = resolveTaskIds(parsed, projectsStore.projects, labelsStore.labels);
const resolved = resolveTaskIds(parsed, allProjects.value, allTags.value);
await tasksStore.createTask({
title: resolved.title,
@ -296,18 +316,19 @@
async function handleLogout() {
await authStore.signOut();
projectsStore.clear();
labelsStore.clear();
tagMutations.stopSync();
goto('/login');
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await todoStore.initialize();
// Initialize local-first databases (opens IndexedDB, seeds guest data)
await Promise.all([todoStore.initialize(), tagLocalStore.initialize()]);
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
todoStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
todoStore.startSync(getToken);
tagMutations.startSync(getToken);
}
// Initialize split-panel from URL/localStorage
@ -319,12 +340,11 @@
// Show guest welcome modal on first visit
initGuestWelcome();
// Load projects from IndexedDB (guest seed or synced data)
await projectsStore.fetchProjects();
// Tags and projects are now loaded reactively via useLiveQuery — no fetch needed
// Labels and user settings need auth (central mana-core-auth service)
// User settings need auth
if (authStore.isAuthenticated) {
await Promise.all([labelsStore.fetchLabels(), userSettings.load()]);
await userSettings.load();
}
// Redirect to start page if on root and a custom start page is set
@ -399,10 +419,10 @@
<!-- 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',
tags={allTags.value.map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#6b7280',
}))}
selectedIds={viewStore.filterLabelIds}
onToggle={(tagId) => {

View file

@ -1,17 +1,20 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, getContext } from 'svelte';
import { format, addDays, subDays, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import { Sparkle, ArrowDown } from '@manacore/shared-icons';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { applyTaskFilters } from '$lib/utils/task-filters';
import { filterOverdue, filterToday, filterCompleted } from '$lib/data/task-queries';
import TaskList from '$lib/components/TaskList.svelte';
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
import { TaskListSkeleton } from '$lib/components/skeletons';
import type { Task } from '@todo/shared';
let isLoading = $state(true);
// Live tasks from layout context — auto-updates on IndexedDB changes
const allTasks: { readonly value: Task[] } = getContext('tasks');
let tipDismissed = $state(false);
// Build filter criteria from viewStore (reactive)
@ -26,30 +29,20 @@
return applyTaskFilters(tasks, filterCriteria);
}
onMount(async () => {
onMount(() => {
viewStore.setToday();
try {
// Fetch tasks (works in both guest and authenticated mode)
await tasksStore.fetchAllTasks();
} catch (error) {
console.error('Failed to load tasks:', error);
}
isLoading = false;
});
// Derived task lists (with filters applied)
let overdueTasks = $derived(applyFilters(tasksStore.overdueTasks));
let todayTasks = $derived(applyFilters(tasksStore.todayTasks));
let completedTasks = $derived(applyFilters(tasksStore.completedTasks));
// Derived task lists (with filters applied) — automatically reactive via liveQuery
let overdueTasks = $derived(applyFilters(filterOverdue(allTasks.value)));
let todayTasks = $derived(applyFilters(filterToday(allTasks.value)));
let completedTasks = $derived(applyFilters(filterCompleted(allTasks.value)));
// Tomorrow's tasks
let tomorrowDate = $derived(addDays(startOfDay(new Date()), 1));
let dayAfterTomorrowDate = $derived(addDays(startOfDay(new Date()), 2));
let tomorrowTasks = $derived(
applyFilters(
tasksStore.tasks.filter((task) => {
allTasks.value.filter((task) => {
if (!task.dueDate || task.isCompleted) return false;
const taskDate = startOfDay(new Date(task.dueDate));
return taskDate.getTime() === tomorrowDate.getTime();
@ -66,7 +59,7 @@
for (let i = 2; i <= 7; i++) {
const date = addDays(today, i);
const dayTasks = applyFilters(
tasksStore.tasks.filter((task) => {
allTasks.value.filter((task) => {
if (!task.dueDate || task.isCompleted) return false;
const taskDate = startOfDay(new Date(task.dueDate));
return taskDate.getTime() === date.getTime();
@ -122,23 +115,20 @@
// Drag and drop handler - uses optimistic updates for smooth UX
async function handleTaskDrop(taskId: string, targetDate: Date | 'completed' | 'overdue') {
const task = tasksStore.tasks.find((t) => t.id === taskId);
const task = allTasks.value.find((t) => t.id === taskId);
if (!task) return;
if (targetDate === 'completed') {
// Mark task as completed (optimistic)
if (!task.isCompleted) {
await tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
}
} else if (targetDate === 'overdue') {
// Set to yesterday (optimistic)
const yesterday = subDays(startOfDay(new Date()), 1);
await tasksStore.updateTaskOptimistic(taskId, {
dueDate: yesterday.toISOString(),
isCompleted: task.isCompleted ? false : undefined,
});
} else {
// Set to specific date (optimistic)
await tasksStore.updateTaskOptimistic(taskId, {
dueDate: targetDate.toISOString(),
isCompleted: task.isCompleted ? false : undefined,
@ -152,8 +142,10 @@
</svelte:head>
<div class="unified-view">
{#if isLoading || tasksStore.loading}
<TaskListSkeleton sections={3} tasksPerSection={3} />
{#if allTasks.error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
{allTasks.error}
</div>
{:else if tasksStore.error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
{tasksStore.error}

View file

@ -5,7 +5,10 @@
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import { todoSettings, type TodoView, type KanbanCardSize } from '$lib/stores/settings.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import {
@ -51,13 +54,13 @@
// Project options for quick add (computed)
let projectOptions = $derived([
{ value: null, label: 'Inbox' },
...projectsStore.projects.map((p) => ({ value: p.id, label: p.name })),
...projectsCtx.value.map((p) => ({ value: p.id, label: p.name })),
]);
onMount(async () => {
// Load user settings and projects from server (only if authenticated)
if (authStore.isAuthenticated) {
await Promise.all([userSettings.load(), projectsStore.fetchProjects()]);
await userSettings.load();
}
// Initialize todo settings from localStorage

View file

@ -1,9 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, getContext } from 'svelte';
import { spiralStore } from '$lib/stores/spiral.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import SpiralCanvas from '$lib/components/SpiralCanvas.svelte';
import { visualizeImageEmoji, COLORS, type ColorDefinition } from '@manacore/spiral-db';
import type { Task } from '@todo/shared';
// Live tasks from layout context
const allTasks: { readonly value: Task[] } = getContext('tasks');
// Get colors as array for iteration
const colorsArray: ColorDefinition[] = Object.values(COLORS);
@ -19,16 +22,10 @@
let emojiView = $derived(spiralStore.image ? visualizeImageEmoji(spiralStore.image) : '');
// Import todos from main store on mount
onMount(async () => {
// Fetch tasks if not already loaded
if (tasksStore.tasks.length === 0) {
await tasksStore.fetchTasks({});
}
// Import existing todos into spiral DB
if (tasksStore.tasks.length > 0) {
onMount(() => {
if (allTasks.value.length > 0) {
spiralStore.importTodos(
tasksStore.tasks.map((t) => ({
allTasks.value.map((t) => ({
title: t.title,
description: t.description,
priority: t.priority,
@ -93,7 +90,7 @@
function handleReimport() {
spiralStore.importTodos(
tasksStore.tasks.map((t) => ({
allTasks.value.map((t) => ({
title: t.title,
description: t.description,
priority: t.priority,

View file

@ -1,47 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { CaretLeft, Tag } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { labelsStore } from '$lib/stores/labels.svelte';
import { CaretLeft, Tag as TagIcon } from '@manacore/shared-icons';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { filterByLabel } from '$lib/data/task-queries';
import TaskList from '$lib/components/TaskList.svelte';
import { TaskListSkeleton } from '$lib/components/skeletons';
import type { Task } from '@todo/shared';
let isLoading = $state(true);
// Live data from layout context
const allTasks: { readonly value: Task[] } = getContext('tasks');
const allTags: { readonly value: Tag[] } = getContext('tags');
// Get tag ID from URL
const tagId = $derived($page.params.id ?? '');
// Get tag from store
const tag = $derived(labelsStore.getById(tagId));
// Get tag — reactively via liveQuery
const tag = $derived(allTags.value.find((t) => t.id === tagId));
// Get tasks with this tag
const tagTasks = $derived(tagId ? tasksStore.getTasksByLabel(tagId) : []);
// Get tasks with this tag — reactively via liveQuery
const tagTasks = $derived(tagId ? filterByLabel(allTasks.value, tagId) : []);
const incompleteTasks = $derived(tagTasks.filter((t) => !t.isCompleted));
const completedTasks = $derived(tagTasks.filter((t) => t.isCompleted));
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
try {
// Ensure tags are loaded
if (labelsStore.labels.length === 0) {
await labelsStore.fetchLabels();
}
// Fetch all tasks to filter by tag
await tasksStore.fetchAllTasks();
} catch (error) {
console.error('Failed to load data:', error);
}
isLoading = false;
});
</script>
<svelte:head>
@ -65,16 +45,14 @@
{/if}
</div>
<a href="/tags" class="manage-button" aria-label="Tags verwalten">
<Tag size={20} weight="bold" />
<TagIcon size={20} weight="bold" />
</a>
</header>
{#if isLoading}
<TaskListSkeleton />
{:else if !tag}
{#if !tag}
<div class="empty-state">
<div class="empty-icon">
<Tag size={40} weight="light" />
<TagIcon size={40} weight="light" />
</div>
<h2 class="empty-title">Tag nicht gefunden</h2>
<p class="empty-description">Dieser Tag existiert nicht mehr.</p>
@ -83,7 +61,7 @@
{:else if tagTasks.length === 0}
<div class="empty-state">
<div class="empty-icon" style="background-color: {tag.color}20">
<Tag size={40} weight="light" style="color: {tag.color}" />
<TagIcon size={40} weight="light" style="color: {tag.color}" />
</div>
<h2 class="empty-title">Keine Aufgaben</h2>
<p class="empty-description">

View file

@ -1,16 +1,19 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import { TagList, TagEditModal, ConfirmationModal, type Tag } from '@manacore/shared-ui';
import { TagList, TagEditModal, ConfirmationModal, type Tag as UITag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft, Check } from '@manacore/shared-icons';
import { labelsStore } from '$lib/stores/labels.svelte';
import type { Label } from '@todo/shared';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
// Live tags from layout context
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
let searchQuery = $state('');
let showModal = $state(false);
let editingLabel = $state<Label | null>(null);
let editingTag = $state<Tag | null>(null);
let showDeleteConfirm = $state(false);
let labelToDelete = $state<Tag | null>(null);
let tagToDelete = $state<UITag | null>(null);
// Inline create state
let newTagName = $state('');
@ -31,18 +34,18 @@
'#6b7280', // gray
];
const filteredLabels = $derived.by(() => {
if (!searchQuery.trim()) return labelsStore.labels;
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return tagsCtx.value;
const query = searchQuery.toLowerCase();
return labelsStore.labels.filter((l) => l.name.toLowerCase().includes(query));
return tagsCtx.value.filter((t) => t.name.toLowerCase().includes(query));
});
// Convert Label to Tag type for shared-ui components
function labelToTag(label: Label): Tag {
// Convert Tag to UITag type for shared-ui components
function tagToUITag(tag: Tag): UITag {
return {
id: label.id,
name: label.name,
color: label.color,
id: tag.id,
name: tag.name,
color: tag.color,
};
}
@ -52,7 +55,7 @@
isCreating = true;
try {
await labelsStore.createLabel({ name: newTagName.trim(), color: newTagColor });
await tagMutations.createTag({ name: newTagName.trim(), color: newTagColor });
newTagName = '';
newTagColor = '#8b5cf6';
} catch (e) {
@ -69,68 +72,62 @@
}
}
function openEditModal(tag: Tag) {
const label = labelsStore.labels.find((l) => l.id === tag.id);
if (label) {
editingLabel = label;
function openEditModal(uiTag: UITag) {
const tag = tagsCtx.value.find((t) => t.id === uiTag.id);
if (tag) {
editingTag = tag;
showModal = true;
}
}
function closeModal() {
showModal = false;
editingLabel = null;
editingTag = null;
}
async function handleSave(name: string, color: string) {
try {
if (editingLabel) {
await labelsStore.updateLabel(editingLabel.id, { name, color });
if (editingTag) {
await tagMutations.updateTag(editingTag.id, { name, color });
}
closeModal();
} catch (e) {
console.error('Failed to save label:', e);
console.error('Failed to save tag:', e);
}
}
async function handleDelete() {
if (!editingLabel) return;
if (!editingTag) return;
try {
await labelsStore.deleteLabel(editingLabel.id);
await tagMutations.deleteTag(editingTag.id);
closeModal();
} catch (e) {
console.error('Failed to delete label:', e);
console.error('Failed to delete tag:', e);
}
}
function handleDeleteFromList(tag: Tag) {
labelToDelete = tag;
function handleDeleteFromList(uiTag: UITag) {
tagToDelete = uiTag;
showDeleteConfirm = true;
}
async function confirmDeleteLabel() {
if (!labelToDelete) return;
async function confirmDeleteTag() {
if (!tagToDelete) return;
try {
await labelsStore.deleteLabel(labelToDelete.id);
await tagMutations.deleteTag(tagToDelete.id);
} catch (e) {
console.error('Failed to delete label:', e);
console.error('Failed to delete tag:', e);
} finally {
showDeleteConfirm = false;
labelToDelete = null;
tagToDelete = null;
}
}
function handleTagClick(tag: Tag) {
goto(`/tag/${tag.id}`);
function handleTagClick(uiTag: UITag) {
goto(`/tag/${uiTag.id}`);
}
onMount(() => {
if (labelsStore.labels.length === 0) {
labelsStore.fetchLabels();
}
});
</script>
<svelte:head>
@ -213,16 +210,16 @@
/>
</div>
{#if labelsStore.error}
{#if tagMutations.error}
<div class="error-banner" role="alert">
<span>{labelsStore.error}</span>
<span>{tagMutations.error}</span>
</div>
{/if}
<!-- Tag List using shared component -->
<TagList
tags={filteredLabels.map(labelToTag)}
loading={labelsStore.loading}
tags={filteredTags.map(tagToUITag)}
loading={false}
onEdit={openEditModal}
onDelete={handleDeleteFromList}
onClick={handleTagClick}
@ -232,29 +229,29 @@
: 'Erstelle deinen ersten Tag'}
/>
{#if !labelsStore.loading && labelsStore.labels.length > 0}
{#if tagsCtx.value.length > 0}
<p class="tags-count">
{labelsStore.labels.length}
{labelsStore.labels.length === 1 ? 'Tag' : 'Tags'}
{tagsCtx.value.length}
{tagsCtx.value.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
</div>
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingLabel ? labelToTag(editingLabel) : null}
tag={editingTag ? tagToUITag(editingTag) : null}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingLabel ? handleDelete : undefined}
title={editingLabel ? 'Tag bearbeiten' : 'Neuer Tag'}
saveLabel={editingLabel ? 'Speichern' : 'Erstellen'}
onDelete={editingTag ? handleDelete : undefined}
title={editingTag ? 'Tag bearbeiten' : 'Neuer Tag'}
saveLabel={editingTag ? 'Speichern' : 'Erstellen'}
deleteLabel="Löschen"
cancelLabel="Abbrechen"
namePlaceholder="Tag Name"
colorLabel="Farbe"
previewLabel="Vorschau"
deleteConfirmMessage={`Tag "${editingLabel?.name || ''}" wirklich löschen?`}
deleteConfirmMessage={`Tag "${editingTag?.name || ''}" wirklich löschen?`}
/>
<!-- Delete confirmation modal -->
@ -262,12 +259,12 @@
visible={showDeleteConfirm}
onClose={() => {
showDeleteConfirm = false;
labelToDelete = null;
tagToDelete = null;
}}
onConfirm={confirmDeleteLabel}
onConfirm={confirmDeleteTag}
variant="danger"
title="Tag löschen?"
message={`Der Tag "${labelToDelete?.name ?? ''}" wird unwiderruflich gelöscht.`}
message={`Der Tag "${tagToDelete?.name ?? ''}" wird unwiderruflich gelöscht.`}
confirmLabel="Löschen"
cancelLabel="Abbrechen"
/>

View file

@ -1,20 +1,13 @@
/**
* Tag Store - Uses shared createTagStore backed by central mana-core-auth
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
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 {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -9,7 +9,11 @@
TagStrip,
} from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
import { tagStore } from '$lib/stores/tags.svelte';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
// Extend QuickInputItem for zitare-specific search results with href navigation
interface ZitareSearchItem extends QuickInputItem {
@ -31,11 +35,15 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { setContext } from 'svelte';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { QUOTES, type Quote } from '@zitare/content';
import { zitareStore } from '$lib/data/local-store';
const allTags = useAllSharedTags();
setContext('tags', allTags);
let showGuestWelcome = $state(false);
// App switcher items
@ -233,8 +241,8 @@
}
async function handleAuthReady() {
// Initialize local-first database
await zitareStore.initialize();
// Initialize local-first databases (app + shared tags)
await Promise.all([zitareStore.initialize(), tagLocalStore.initialize()]);
// Initialize settings
zitareSettings.initialize();
@ -244,9 +252,10 @@
await listsStore.loadLists();
if (authStore.isAuthenticated) {
zitareStore.startSync(() => authStore.getValidToken());
const getToken = () => authStore.getValidToken();
zitareStore.startSync(getToken);
tagMutations.startSync(getToken);
userSettings.load();
tagStore.fetchTags();
} else if (shouldShowGuestWelcome('zitare')) {
showGuestWelcome = true;
}
@ -296,7 +305,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',
@ -305,7 +314,6 @@
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
loading={tagStore.loading}
/>
{/if}

View file

@ -1,12 +1,9 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tagMutations } from '@manacore/shared-stores';
import type { Tag } from '@manacore/shared-tags';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
@ -19,13 +16,11 @@
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
{#each tagsCtx.value 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>
<span>{tag.name}</span>

View file

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

View file

@ -21,3 +21,19 @@ export {
type SimpleNavigationOptions,
} from './navigation-simple';
export { createTagStore, type TagStore, type TagStoreConfig } from './tags.svelte';
export {
tagLocalStore,
tagCollection,
tagGroupCollection,
tagMutations,
useAllTags,
useAllTagGroups,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
toTag,
toTagGroup,
type LocalTag,
type LocalTagGroup,
} from './tags-local.svelte';

View file

@ -0,0 +1,304 @@
/**
* Local-First Tag Store (Shared Across All Apps)
*
* Uses a shared IndexedDB database ('manacore-tags') that all apps read from.
* Tags are synced to the server via mana-sync, just like any other collection.
*
* Architecture:
* - Tags + TagGroups shared IndexedDB ('manacore-tags'), one DB for all apps
* - TagLinks (junction) stay in each app's own IndexedDB (app-specific)
* - Guest mode default seed tags (Arbeit, Persönlich, Familie, Wichtig)
* - Cross-app all apps import the same store, read from the same DB
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import type {
Tag,
TagGroup,
CreateTagInput,
UpdateTagInput,
CreateTagGroupInput,
UpdateTagGroupInput,
} from '@manacore/shared-tags';
// ─── Local Types ───────────────────────────────────────────
export interface LocalTag extends BaseRecord {
name: string;
color: string;
icon?: string | null;
groupId?: string | null;
userId?: string;
sortOrder: number;
}
export interface LocalTagGroup extends BaseRecord {
name: string;
color: string;
icon?: string | null;
userId?: string;
sortOrder: number;
}
// ─── Type Converters ───────────────────────────────────────
export function toTag(local: LocalTag): Tag {
return {
id: local.id,
userId: local.userId ?? 'guest',
name: local.name,
color: local.color,
icon: local.icon,
groupId: local.groupId,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTagGroup(local: LocalTagGroup): TagGroup {
return {
id: local.id,
userId: local.userId ?? 'guest',
name: local.name,
color: local.color,
icon: local.icon,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Guest Seed Data ───────────────────────────────────────
const guestTags: LocalTag[] = [
{
id: 'tag-arbeit',
name: 'Arbeit',
color: '#3B82F6',
icon: 'Briefcase',
sortOrder: 0,
},
{
id: 'tag-persoenlich',
name: 'Persönlich',
color: '#10B981',
icon: 'User',
sortOrder: 1,
},
{
id: 'tag-familie',
name: 'Familie',
color: '#EC4899',
icon: 'Heart',
sortOrder: 2,
},
{
id: 'tag-wichtig',
name: 'Wichtig',
color: '#EF4444',
icon: 'Star',
sortOrder: 3,
},
];
// ─── Shared Store Instance ─────────────────────────────────
const SYNC_SERVER_URL =
(typeof window !== 'undefined' &&
(window as unknown as { __PUBLIC_SYNC_SERVER_URL__?: string }).__PUBLIC_SYNC_SERVER_URL__) ||
(typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_SYNC_SERVER_URL) ||
'http://localhost:3050';
export const tagLocalStore = createLocalStore({
appId: 'tags',
collections: [
{
name: 'tags',
indexes: ['name', 'groupId', 'sortOrder'],
guestSeed: guestTags,
},
{
name: 'tagGroups',
indexes: ['sortOrder'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const tagCollection = tagLocalStore.collection<LocalTag>('tags');
export const tagGroupCollection = tagLocalStore.collection<LocalTagGroup>('tagGroups');
// ─── Live Query Hooks ──────────────────────────────────────
/** All tags, sorted by sortOrder. Auto-updates on any change. */
export function useAllTags() {
return useLiveQueryWithDefault(async () => {
const locals = await tagCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
return locals.map(toTag);
}, [] as Tag[]);
}
/** All tag groups, sorted by sortOrder. Auto-updates on any change. */
export function useAllTagGroups() {
return useLiveQueryWithDefault(async () => {
const locals = await tagGroupCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
return locals.map(toTagGroup);
}, [] as TagGroup[]);
}
// ─── Pure Query Helpers ────────────────────────────────────
export function getTagById(tags: Tag[], id: string): Tag | undefined {
return tags.find((t) => t.id === id);
}
export function getTagsByIds(tags: Tag[], ids: string[]): Tag[] {
return tags.filter((t) => ids.includes(t.id));
}
export function getTagColor(tags: Tag[], id: string): string {
return tags.find((t) => t.id === id)?.color || '#6b7280';
}
export function getTagsByGroup(tags: Tag[], groupId: string | null): Tag[] {
return tags.filter((t) => (t.groupId || null) === groupId);
}
// ─── Mutation Service ──────────────────────────────────────
let error = $state<string | null>(null);
export const tagMutations = {
get error() {
return error;
},
// === Store Lifecycle ===
async initialize() {
await tagLocalStore.initialize();
},
startSync(getToken: () => Promise<string | null>) {
tagLocalStore.startSync(getToken);
},
stopSync() {
tagLocalStore.stopSync();
},
// === Tags ===
async createTag(data: CreateTagInput): Promise<Tag> {
error = null;
try {
const count = await tagCollection.count();
const newLocal: LocalTag = {
id: crypto.randomUUID(),
name: data.name,
color: data.color ?? '#3B82F6',
icon: data.icon ?? null,
groupId: data.groupId ?? null,
sortOrder: data.sortOrder ?? count,
};
const inserted = await tagCollection.insert(newLocal);
return toTag(inserted);
} 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 updated = await tagCollection.update(id, data as Partial<LocalTag>);
if (updated) return toTag(updated);
throw new Error('Tag not found');
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update tag';
throw e;
}
},
async deleteTag(id: string): Promise<void> {
error = null;
try {
await tagCollection.delete(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete tag';
throw e;
}
},
// === Groups ===
async createGroup(data: CreateTagGroupInput): Promise<TagGroup> {
error = null;
try {
const count = await tagGroupCollection.count();
const newLocal: LocalTagGroup = {
id: crypto.randomUUID(),
name: data.name,
color: data.color ?? '#6b7280',
icon: data.icon ?? null,
sortOrder: data.sortOrder ?? count,
};
const inserted = await tagGroupCollection.insert(newLocal);
return toTagGroup(inserted);
} 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 updated = await tagGroupCollection.update(id, data as Partial<LocalTagGroup>);
if (updated) return toTagGroup(updated);
throw new Error('Tag group not found');
} 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 tagGroupCollection.delete(id);
// Clear groupId on tags in deleted group
const tagsInGroup = await tagCollection.getAll({ groupId: id } as Partial<LocalTag>);
for (const tag of tagsInGroup) {
await tagCollection.update(tag.id, { groupId: null } as Partial<LocalTag>);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete tag group';
throw e;
}
},
async reorderGroups(ids: string[]): Promise<void> {
error = null;
try {
for (let i = 0; i < ids.length; i++) {
await tagGroupCollection.update(ids[i], { sortOrder: i } as Partial<LocalTagGroup>);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder groups';
throw e;
}
},
};

2685
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff