mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
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:
parent
4f8c3d680c
commit
709e781ecd
8 changed files with 206 additions and 3 deletions
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue