feat(manacore): add DnD tag support to calendar and contacts modules

Calendar:
- Add tagIds field to LocalEvent and CalendarEvent types
- Add updateTagIds() to eventsStore
- Add dropTarget on agenda view events, tag badge display
- Register tagDropHandler for passive event→tag drops

Contacts:
- Add tagIds field to LocalContact and Contact types (alongside existing tags)
- Add updateTagIds() to contactsStore
- Add dropTarget on contact list items, tag badge display
- Register tagDropHandler for passive contact→tag drops

Both modules now support:
- Drag tag from TagStrip onto item (Layer 1: pointer events)
- Drag item onto tag in TagStrip (Layer 2: passive overlay)
- Tag badges shown inline on items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 21:38:33 +02:00
parent 4f8c3d680c
commit 709e781ecd
8 changed files with 206 additions and 3 deletions

View file

@ -41,6 +41,7 @@ export function toCalendarEvent(local: LocalEvent): CalendarEvent {
recurrenceRule: local.recurrenceRule ?? null,
parentEventId: null,
color: local.color ?? null,
tagIds: local.tagIds ?? [],
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -122,6 +122,16 @@ export const eventsStore = {
}
},
/**
* Update tag IDs on an event (merge-safe).
*/
async updateTagIds(id: string, tagIds: string[]) {
await db.table('events').update(id, {
tagIds,
updatedAt: new Date().toISOString(),
});
},
// ========== Draft Event Methods ==========
createDraftEvent(data: Partial<CalendarEvent>) {
@ -138,6 +148,7 @@ export const eventsStore = {
recurrenceRule: null,
parentEventId: null,
color: data.color || null,
tagIds: data.tagIds || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

View file

@ -23,6 +23,7 @@ export interface LocalEvent extends BaseRecord {
recurrenceRule?: string | null;
color?: string | null;
reminders?: unknown | null;
tagIds?: string[];
}
export type CalendarViewType = 'week' | 'month' | 'agenda';
@ -40,6 +41,7 @@ export interface CalendarEvent {
recurrenceRule: string | null;
parentEventId: string | null;
color: string | null;
tagIds: string[];
createdAt: string;
updatedAt: string;
}

View file

@ -27,6 +27,7 @@ export function toContact(local: LocalContact): Contact {
photoUrl: local.photoUrl || null,
birthday: local.birthday || null,
tags: (local.tags || []).map((name, i) => ({ id: `tag-${i}`, name, color: null })),
tagIds: local.tagIds ?? [],
isFavorite: local.isFavorite ?? false,
isArchived: local.isArchived ?? false,
createdAt: local.createdAt ?? new Date().toISOString(),

View file

@ -69,6 +69,13 @@ export const contactsStore = {
});
},
async updateTagIds(id: string, tagIds: string[]) {
await contactTable.update(id, {
tagIds,
updatedAt: new Date().toISOString(),
});
},
async toggleArchive(id: string) {
const local = await contactTable.get(id);
if (!local) return;

View file

@ -16,6 +16,7 @@ export interface LocalContact extends BaseRecord {
photoUrl?: string;
birthday?: string;
tags?: string[];
tagIds?: string[];
isFavorite?: boolean;
isArchived?: boolean;
}
@ -36,6 +37,7 @@ export interface Contact {
photoUrl?: string | null;
birthday?: string | null;
tags: Array<{ id: string; name: string; color: string | null }>;
tagIds: string[];
isFavorite: boolean;
isArchived: boolean;
createdAt: string;

View file

@ -1,5 +1,8 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, onMount } from 'svelte';
import { dropTarget } from '@manacore/shared-ui/dnd';
import type { DragPayload, TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags } from '$lib/stores/tags.svelte';
import { calendarViewStore } from '$lib/modules/calendar/stores/view.svelte';
import { eventsStore } from '$lib/modules/calendar/stores/events.svelte';
import {
@ -26,6 +29,53 @@
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
// ── DnD: tag support ────────────────────────────────────
const globalTags = useAllTags();
const tagMap = $derived(new Map((globalTags.value ?? []).map((t) => [t.id, t])));
function getEventTags(event: CalendarEvent) {
return (event.tagIds ?? [])
.map((id) => tagMap.get(id))
.filter((t): t is NonNullable<typeof t> => t != null);
}
function handleTagDrop(event: CalendarEvent, payload: DragPayload) {
const tagData = payload.data as TagDragData;
const current = event.tagIds ?? [];
if (!current.includes(tagData.id)) {
eventsStore.updateTagIds(event.id, [...current, tagData.id]);
}
}
function tagNotAlreadyOnEvent(event: CalendarEvent) {
return (payload: DragPayload) => {
const tagData = payload.data as TagDragData;
return !(event.tagIds ?? []).includes(tagData.id);
};
}
// Register passive handler for task→tag direction
const tagDropCtx = getContext<{
set: (handler: (tagId: string, payload: DragPayload) => void) => void;
clear: () => void;
}>('tagDropHandler');
onMount(() => {
tagDropCtx?.set(async (tagId: string, payload: DragPayload) => {
const data = payload.data as { id: string };
// Check if dropped item is an event
if (payload.type === 'event') {
const event = eventsCtx.value.find((e) => e.id === data.id);
if (!event) return;
const current = event.tagIds ?? [];
if (!current.includes(tagId)) {
eventsStore.updateTagIds(data.id, [...current, tagId]);
}
}
});
return () => tagDropCtx?.clear();
});
// Filtered events based on visible calendars
let visibleEvents = $derived(filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value));
@ -339,6 +389,11 @@
<button
onclick={() => openEditEvent(event)}
class="flex w-full items-start gap-3 rounded-lg border border-border bg-card p-3 text-left hover:border-primary/50 transition-colors"
use:dropTarget={{
accepts: ['tag'],
onDrop: (payload) => handleTagDrop(event, payload),
canDrop: tagNotAlreadyOnEvent(event),
}}
>
<div
class="mt-1 h-3 w-3 flex-shrink-0 rounded-full"
@ -349,7 +404,7 @@
></div>
<div class="flex-1 min-w-0">
<div class="font-medium text-foreground">{event.title}</div>
<div class="text-sm text-muted-foreground">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
{#if event.isAllDay}
Ganztägig
{:else}
@ -362,6 +417,18 @@
<span class="ml-2">📍 {event.location}</span>
{/if}
</div>
{#if getEventTags(event).length > 0}
<div class="mt-1 flex gap-1">
{#each getEventTags(event).slice(0, 3) as tag (tag.id)}
<span
class="inline-flex 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>
{/if}
</div>
</button>
{/each}
@ -496,4 +563,26 @@
.week-grid {
min-height: 100%;
}
: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 { getContext } from 'svelte';
import { getContext, onMount } 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 {
type Contact,
contactsFilterStore,
@ -53,6 +56,52 @@
let groups = $derived(groupByLetter(sorted, contactsFilterStore.sortField));
let letters = $derived(Object.keys(groups).sort());
// ── DnD: tag support ────────────────────────────────────
const globalTags = useAllTags();
const tagMap = $derived(new Map((globalTags.value ?? []).map((t) => [t.id, t])));
function getContactTags(contact: Contact) {
return (contact.tagIds ?? [])
.map((id) => tagMap.get(id))
.filter((t): t is NonNullable<typeof t> => t != null);
}
function handleTagDrop(contact: Contact, payload: DragPayload) {
const tagData = payload.data as TagDragData;
const current = contact.tagIds ?? [];
if (!current.includes(tagData.id)) {
contactsStore.updateTagIds(contact.id, [...current, tagData.id]);
}
}
function tagNotAlreadyOnContact(contact: Contact) {
return (payload: DragPayload) => {
const tagData = payload.data as TagDragData;
return !(contact.tagIds ?? []).includes(tagData.id);
};
}
// Register passive handler for contact→tag direction
const tagDropCtx = getContext<{
set: (handler: (tagId: string, payload: DragPayload) => void) => void;
clear: () => void;
}>('tagDropHandler');
onMount(() => {
tagDropCtx?.set(async (tagId: string, payload: DragPayload) => {
const data = payload.data as { id: string };
if (payload.type === 'contact') {
const contact = allContacts.find((c) => c.id === data.id);
if (!contact) return;
const current = contact.tagIds ?? [];
if (!current.includes(tagId)) {
contactsStore.updateTagIds(data.id, [...current, tagId]);
}
}
});
return () => tagDropCtx?.clear();
});
// Handlers
function handleToggleFavorite(e: MouseEvent, id: string) {
e.stopPropagation();
@ -183,6 +232,11 @@
<a
href="/contacts/{contact.id}"
class="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 transition-colors hover:border-border hover:bg-card group"
use:dropTarget={{
accepts: ['tag'],
onDrop: (payload) => handleTagDrop(contact, payload),
canDrop: tagNotAlreadyOnContact(contact),
}}
>
<!-- Avatar -->
<div
@ -214,6 +268,18 @@
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
</div>
{/if}
{#if getContactTags(contact).length > 0}
<div class="mt-0.5 flex gap-1">
{#each getContactTags(contact).slice(0, 3) as tag (tag.id)}
<span
class="inline-flex 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>
{/if}
</div>
<!-- Actions (visible on hover) -->
@ -342,3 +408,27 @@
</div>
</div>
{/if}
<style>
: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>