feat(manacore, shared-ui): integrate cross-type DnD into unified app

- Shared TagStrip: add dragSource on tag pills + passiveDropZone for item→tag
  drops. New onTagDrop and dropAccepts props. DnD CSS for hover/success states.
- Unified app layout: add DragPreview, context-based tagDropHandler so child
  pages can register their own drop logic.
- Todo module: add updateLabels() to tasks store (with metadata merge).
- Todo page: add dropTarget on task items, tag badge display via getTaskTags(),
  register tagDropHandler for passive task→tag drops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 21:30:40 +02:00
parent 3bf40fc61f
commit bd67e8d20b
4 changed files with 174 additions and 2 deletions

View file

@ -110,6 +110,15 @@ export const tasksStore = {
});
},
async updateLabels(id: string, labelIds: string[]) {
const existing = await taskTable.get(id);
const existingMeta = (existing?.metadata as Record<string, unknown>) ?? {};
await taskTable.update(id, {
metadata: { ...existingMeta, labelIds },
updatedAt: new Date().toISOString(),
});
},
async reorderTasks(taskIds: string[]) {
for (let i = 0; i < taskIds.length; i++) {
await taskTable.update(taskIds[i], {

View file

@ -2,11 +2,11 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { onMount, setContext } from 'svelte';
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import SessionWarning from '$lib/components/SessionWarning.svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, TagStrip } from '@manacore/shared-ui';
import { PillNavigation, TagStrip, DragPreview, ActionZone } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, SpotlightAction } from '@manacore/shared-ui';
import { tagLocalStore, tagMutations, useAllTags } from '$lib/stores/tags.svelte';
import { linkLocalStore, linkMutations } from '@manacore/shared-links';
@ -110,6 +110,19 @@
isTagStripVisible = !isTagStripVisible;
}
// DnD: tag drop handler — set by child pages via context
import type { DragPayload } from '@manacore/shared-ui/dnd';
let tagDropHandler = $state<((tagId: string, payload: DragPayload) => void) | null>(null);
setContext('tagDropHandler', {
set(handler: (tagId: string, payload: DragPayload) => void) {
tagDropHandler = handler;
},
clear() {
tagDropHandler = null;
},
});
// Navigation items for ManaCore
const baseNavItems: PillNavItem[] = [
{ href: '/home', label: 'Home', icon: 'home' },
@ -353,11 +366,15 @@
selectedIds={[]}
onToggle={() => {}}
onClear={() => {}}
onTagDrop={tagDropHandler ?? undefined}
managementHref="/tags"
loading={allTags.loading}
/>
{/if}
<!-- DnD: floating preview + action zones -->
<DragPreview />
<!-- Main content -->
<main class="pb-24">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">

View file

@ -1,6 +1,10 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import { dropTarget } from '@manacore/shared-ui/dnd';
import type { DragPayload, TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import {
type Task,
type LocalLabel,
@ -8,6 +12,7 @@
type LocalTodoProject,
type TaskPriority,
tasksStore,
taskTable,
viewStore,
filterIncomplete,
filterCompleted,
@ -66,6 +71,15 @@
return () => sub.unsubscribe();
});
// Global tags for resolving labelIds to names/colors
const globalTags = useAllTags();
const tagMap = $derived(new Map((globalTags.value ?? []).map((t) => [t.id, t])));
function getTaskTags(task: Task) {
const ids: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
return ids.map((id) => tagMap.get(id)).filter((t): t is NonNullable<typeof t> => t != null);
}
// Task stats
let stats = $derived(getTaskStats(allTasks));
@ -114,6 +128,42 @@
await tasksStore.deleteTask(task.id);
}
// ── DnD: register tag drop handler for passive drops (task→tag in TagStrip)
const tagDropCtx = getContext<{
set: (handler: (tagId: string, payload: DragPayload) => void) => void;
clear: () => void;
}>('tagDropHandler');
onMount(() => {
tagDropCtx?.set(async (tagId: string, payload: DragPayload) => {
const taskData = payload.data as TagDragData;
const task = await taskTable.get(taskData.id);
if (!task) return;
const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
if (!currentLabels.includes(tagId)) {
tasksStore.updateLabels(taskData.id, [...currentLabels, tagId]);
}
});
return () => tagDropCtx?.clear();
});
// ── DnD: tag dropped onto a task ────────────────────────
function handleTagDrop(task: Task, payload: DragPayload) {
const tagData = payload.data as TagDragData;
const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
if (!currentLabels.includes(tagData.id)) {
tasksStore.updateLabels(task.id, [...currentLabels, tagData.id]);
}
}
function tagNotAlreadyOnTask(task: Task) {
return (payload: DragPayload) => {
const tagData = payload.data as TagDragData;
const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
return !currentLabels.includes(tagData.id);
};
}
// View navigation items
const views = [
{ id: 'inbox', label: 'Inbox', icon: Tray },
@ -293,6 +343,11 @@
role="button"
tabindex="0"
onclick={() => (selectedTaskId = selectedTaskId === task.id ? null : task.id)}
use:dropTarget={{
accepts: ['tag'],
onDrop: (payload) => handleTagDrop(task, payload),
canDrop: tagNotAlreadyOnTask(task),
}}
>
<!-- Completion Toggle -->
<button
@ -337,6 +392,14 @@
{task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks.length} Teilaufgaben
</span>
{/if}
{#each getTaskTags(task).slice(0, 3) as tag (tag.id)}
<span
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium"
style="background: color-mix(in srgb, {tag.color} 15%, transparent); color: {tag.color}"
>
{tag.name}
</span>
{/each}
</div>
<!-- Expanded Detail -->
@ -469,3 +532,28 @@
</div>
{/if}
</div>
<style>
/* DnD: tag hovering over task item */
:global(.mana-drop-target-hover) {
outline: 2px solid var(--color-primary, #6366f1);
outline-offset: -2px;
border-radius: 0.5rem;
background: rgba(99, 102, 241, 0.06) !important;
}
:global(.mana-drop-target-success) {
animation: drop-success 400ms ease-out;
}
@keyframes drop-success {
0% {
outline-color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
100% {
outline-color: transparent;
background: transparent;
}
}
</style>

View file

@ -1,6 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Tag, Plus, X } from '@manacore/shared-icons';
import { dragSource } from '../dnd/drag-source';
import { passiveDropZone } from '../dnd/passive-drop';
import type { DragPayload } from '../dnd/types';
interface TagItem {
id: string;
@ -17,6 +20,10 @@
onToggle: (tagId: string) => void;
/** Called when filter is cleared */
onClear: () => void;
/** Called when an item (task, card, etc.) is dropped on a tag pill */
onTagDrop?: (tagId: string, payload: DragPayload) => void;
/** Drag types accepted for drop-on-tag (default: ['task']) */
dropAccepts?: string[];
/** Link for "Tags verwalten" pill */
managementHref?: string;
/** Loading state */
@ -34,6 +41,8 @@
selectedIds,
onToggle,
onClear,
onTagDrop,
dropAccepts = ['task'],
managementHref = '/tags',
loading = false,
showCreateButton = true,
@ -93,6 +102,16 @@
onclick={() => onToggle(tag.id)}
title={tag.name}
style="--tag-color: {tag.color || '#8b5cf6'}"
use:dragSource={{
type: 'tag',
data: () => ({ id: tag.id, name: tag.name, color: tag.color || '#8b5cf6' }),
}}
use:passiveDropZone={{
accepts: dropAccepts,
onDrop: (payload) => onTagDrop?.(tag.id, payload),
highlightClass: 'tag-drop-highlight',
disabled: !onTagDrop,
}}
>
<span class="tag-dot"></span>
<span class="tag-name">{tag.name}</span>
@ -343,6 +362,45 @@
font-weight: 500;
}
/* DnD: Tag is being dragged */
:global(.tag-pill.mana-drag-source-active) {
opacity: 0.5;
transform: scale(0.95) !important;
}
/* DnD: Item hovering over tag pill */
.tag-drop-highlight {
transform: scale(1.15) !important;
background: var(--tag-color) !important;
border-color: var(--tag-color) !important;
box-shadow: 0 0 16px color-mix(in srgb, var(--tag-color) 40%, transparent) !important;
}
.tag-drop-highlight .tag-dot {
background-color: white !important;
}
.tag-drop-highlight .tag-name {
color: white !important;
}
/* DnD: Success flash after drop */
:global(.tag-pill.mana-passive-zone-success) {
animation: tag-drop-success 400ms ease-out;
}
@keyframes tag-drop-success {
0% {
transform: scale(1.2);
}
50% {
transform: scale(0.95);
}
100% {
transform: scale(1);
}
}
/* Responsive */
@media (max-width: 640px) {
.tag-strip-wrapper {