feat(manacore/web): add tag drag-and-drop to workbench pages

- Mount DragPreview + draggable tag bar on workbench homepage
- Add dropTarget on list items in todo, calendar, contacts ListViews
- Show assigned tags as colored dots on list items
- Tags can be dragged from the tag bar onto any item to assign them
- Drop target highlights with module-colored outline on hover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 00:20:54 +02:00
parent fb5271acc8
commit 06ebc6271d
4 changed files with 219 additions and 4 deletions

View file

@ -10,9 +10,21 @@
import { eventsStore } from './stores/events.svelte';
import { Plus } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import { dropTarget } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { getTagsByIds } from '$lib/stores/tags.svelte';
let { navigate, goBack, params }: ViewProps = $props();
function handleTagDrop(eventId: string, tagData: TagDragData) {
const event = events.find((e) => e.id === eventId);
if (!event) return;
const current = event.tagIds ?? [];
if (!current.includes(tagData.id)) {
eventsStore.updateTagIds(eventId, [...current, tagData.id]);
}
}
let events = $state<LocalEvent[]>([]);
const now = new Date();
@ -121,6 +133,7 @@
</form>
{#each todayEvents as event (event.id)}
{@const eventTags = getTagsByIds(event.tagIds ?? [])}
<button
class="event-card"
onclick={() =>
@ -129,8 +142,22 @@
_siblingIds: todayEvents.map((e) => e.id),
_siblingKey: 'eventId',
})}
use:dropTarget={{
accepts: ['tag'],
onDrop: (p) => handleTagDrop(event.id, p.data as unknown as TagDragData),
canDrop: (p) => !(event.tagIds ?? []).includes((p.data as unknown as TagDragData).id),
}}
>
<p class="event-title">{event.title}</p>
<div class="event-header">
<p class="event-title">{event.title}</p>
{#if eventTags.length > 0}
<div class="event-tags">
{#each eventTags as tag (tag.id)}
<span class="tag-dot" style="background: {tag.color}" title={tag.name}></span>
{/each}
</div>
{/if}
</div>
<p class="event-time-label">
{#if event.allDay}
Ganztägig
@ -277,6 +304,28 @@
:global(.dark) .event-card:hover {
background: rgba(255, 255, 255, 0.06);
}
.event-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.375rem;
}
.event-tags {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.tag-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
:global(.event-card.mana-drop-target-hover) {
outline: 2px solid rgba(59, 130, 246, 0.4);
outline-offset: -2px;
background: rgba(59, 130, 246, 0.06) !important;
}
.event-title {
font-size: 0.8125rem;
font-weight: 500;

View file

@ -10,9 +10,21 @@
import { contactsStore } from './stores/contacts.svelte';
import { Plus, Star } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import { dropTarget } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { getTagsByIds } from '$lib/stores/tags.svelte';
let { navigate, goBack, params }: ViewProps = $props();
function handleTagDrop(contactId: string, tagData: TagDragData) {
const contact = contacts.find((c) => c.id === contactId);
if (!contact) return;
const current = contact.tagIds ?? [];
if (!current.includes(tagData.id)) {
contactsStore.updateTagIds(contactId, [...current, tagData.id]);
}
}
let contacts = $state<LocalContact[]>([]);
let search = $state('');
@ -86,6 +98,7 @@
<div class="contact-list">
{#each filtered() as contact (contact.id)}
{@const contactTags = getTagsByIds(contact.tagIds ?? [])}
<button
class="contact-item"
onclick={() =>
@ -94,6 +107,11 @@
_siblingIds: filtered().map((c) => c.id),
_siblingKey: 'contactId',
})}
use:dropTarget={{
accepts: ['tag'],
onDrop: (p) => handleTagDrop(contact.id, p.data as unknown as TagDragData),
canDrop: (p) => !(contact.tagIds ?? []).includes((p.data as unknown as TagDragData).id),
}}
>
<div class="avatar">{initials(contact)}</div>
<div class="contact-info">
@ -101,6 +119,13 @@
{#if contact.company}
<p class="contact-company">{contact.company}</p>
{/if}
{#if contactTags.length > 0}
<div class="contact-tags">
{#each contactTags as tag (tag.id)}
<span class="tag-dot" style="background: {tag.color}" title={tag.name}></span>
{/each}
</div>
{/if}
</div>
{#if contact.isFavorite}
<span class="fav"><Star size={12} weight="fill" /></span>
@ -251,6 +276,22 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-tags {
display: flex;
gap: 0.25rem;
margin-top: 0.125rem;
}
.tag-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
:global(.contact-item.mana-drop-target-hover) {
outline: 2px solid rgba(34, 197, 94, 0.4);
outline-offset: -2px;
background: rgba(34, 197, 94, 0.06) !important;
}
.fav {
color: #f59e0b;
display: flex;

View file

@ -15,9 +15,25 @@
import { tasksStore } from './stores/tasks.svelte';
import { Circle, Check } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import { dropTarget } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { getTagsByIds } from '$lib/stores/tags.svelte';
let { navigate, goBack, params }: ViewProps = $props();
function getTaskTagIds(task: import('./types').Task): string[] {
return ((task.metadata as Record<string, unknown>)?.labelIds as string[]) ?? [];
}
function handleTagDrop(taskId: string, tagData: TagDragData) {
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
const current = getTaskTagIds(task);
if (!current.includes(tagData.id)) {
tasksStore.updateLabels(taskId, [...current, tagData.id]);
}
}
type ViewFilter = 'inbox' | 'today' | 'overdue';
let filter = $state<ViewFilter>('inbox');
@ -92,6 +108,8 @@
<div class="task-list">
{#each filtered() as task (task.id)}
{@const taskTagIds = getTaskTagIds(task)}
{@const taskTags = getTagsByIds(taskTagIds)}
<button
onclick={() =>
navigate('detail', {
@ -100,6 +118,11 @@
_siblingKey: 'taskId',
})}
class="task-item"
use:dropTarget={{
accepts: ['tag'],
onDrop: (p) => handleTagDrop(task.id, p.data as unknown as TagDragData),
canDrop: (p) => !taskTagIds.includes((p.data as unknown as TagDragData).id),
}}
>
<div
class="checkbox"
@ -114,8 +137,15 @@
</div>
<div class="task-content">
<p class="task-title" class:completed={task.isCompleted}>{task.title}</p>
{#if task.dueDate}
<p class="task-due">{new Date(task.dueDate).toLocaleDateString('de')}</p>
{#if task.dueDate || taskTags.length > 0}
<div class="task-meta">
{#if task.dueDate}
<span class="task-due">{new Date(task.dueDate).toLocaleDateString('de')}</span>
{/if}
{#each taskTags as tag (tag.id)}
<span class="tag-dot" style="background: {tag.color}" title={tag.name}></span>
{/each}
</div>
{/if}
</div>
</button>
@ -282,10 +312,26 @@
:global(.dark) .task-title.completed {
color: #6b7280;
}
.task-meta {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0;
}
.task-due {
font-size: 0.6875rem;
color: #9ca3af;
margin: 0;
}
.tag-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
:global(.task-item.mana-drop-target-hover) {
outline: 2px solid rgba(139, 92, 246, 0.4);
outline-offset: -2px;
background: rgba(139, 92, 246, 0.06) !important;
}
.empty {
padding: 2rem 0;

View file

@ -4,6 +4,19 @@
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
import { getAppEntry } from '$lib/components/workbench/app-registry';
import { createAppSettingsStore } from '@manacore/shared-stores';
import { DragPreview, dragSource } from '@manacore/shared-ui/dnd';
import { useAllTags } from '$lib/stores/tags.svelte';
import type { Tag } from '@manacore/shared-tags';
// ── Tags for drag & drop ───────────────────────────────
const allTags$ = useAllTags();
let allTags = $state<Tag[]>([]);
$effect(() => {
const sub = allTags$.subscribe((val) => {
allTags = val ?? [];
});
return () => sub.unsubscribe();
});
// ── Persisted workbench state ───────────────────────────
const DEFAULT_WIDTH = 480;
@ -131,7 +144,26 @@
<title>Home - ManaCore</title>
</svelte:head>
<DragPreview />
<div class="workbench">
{#if allTags.length > 0}
<div class="tag-bar">
{#each allTags as tag (tag.id)}
<button
class="tag-pill"
use:dragSource={{
type: 'tag',
data: () => ({ id: tag.id, name: tag.name, color: tag.color }),
}}
>
<span class="tag-dot" style="background: {tag.color}"></span>
{tag.name}
</button>
{/each}
</div>
{/if}
<PageCarousel
pages={carouselPages}
defaultWidth={DEFAULT_WIDTH}
@ -172,4 +204,51 @@
flex-direction: column;
position: relative;
}
.tag-bar {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.5rem 1rem 0.25rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
border-radius: 9999px;
border: none;
background: rgba(0, 0, 0, 0.04);
font-size: 0.6875rem;
color: #6b7280;
cursor: grab;
transition: all 0.15s;
user-select: none;
touch-action: none;
}
.tag-pill:hover {
background: rgba(0, 0, 0, 0.08);
color: #374151;
}
:global(.dark) .tag-pill {
background: rgba(255, 255, 255, 0.06);
color: #9ca3af;
}
:global(.dark) .tag-pill:hover {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
.tag-pill:active {
cursor: grabbing;
}
:global(.tag-pill.mana-drag-source-active) {
opacity: 0.4;
transform: scale(0.95);
}
.tag-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
</style>