mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
3bf40fc61f
commit
bd67e8d20b
4 changed files with 174 additions and 2 deletions
|
|
@ -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], {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue