mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
fb5271acc8
commit
06ebc6271d
4 changed files with 219 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue