feat(todo): refactor inline title editing + kanban subtask DnD

- TaskItem: switch title editing to <input>-based pattern (isEditingTitle
  state, single-click to start, blur/Enter saves, Escape cancels)
- KanbanTaskCard: contenteditable span for title editing (blur-to-save),
  add ArrowsOutSimple detail button (hover-only), inline subtask DnD
  with shadow placeholder handling and dropInProgress guard
- SubtaskList: fix DnD reactivity loop — use $state instead of $derived
  for items, add SHADOW_PLACEHOLDER_ITEM_ID filter, dropInProgress flag

fix(guides): remove non-existent allAppsHref prop from PillNavigation
fix(memoro): extend Memory interface with memo_id, timestamps, nullable types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 22:23:31 +02:00
parent 16057964a6
commit 3f0811043e
6 changed files with 522 additions and 612 deletions

View file

@ -158,7 +158,6 @@
showAppSwitcher={true}
{appItems}
{userEmail}
allAppsHref="/apps"
/>
<main class="relative z-0 pb-24" style="padding-top: 0">

View file

@ -19,9 +19,12 @@ export interface QuestionResult {
export interface Memory {
id: string;
memo_id: string;
title: string;
content: string;
metadata?: Record<string, unknown>;
content: string | null;
metadata?: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
class QuestionService {

View file

@ -1,7 +1,8 @@
<script lang="ts">
import type { Subtask } from '@todo/shared';
import { dndzone } from 'svelte-dnd-action';
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { untrack } from 'svelte';
import { Check, Plus, X, DotsSixVertical } from '@manacore/shared-icons';
interface Props {
@ -12,24 +13,36 @@
let { subtasks, onChange }: Props = $props();
let newSubtaskTitle = $state('');
let editingId = $state<string | null>(null);
let editingTitle = $state('');
let items = $state<Subtask[]>([]);
let dropInProgress = false;
// Convert subtasks to items with id for dnd
let items = $derived(
subtasks.map((s, index) => ({
...s,
id: s.id,
order: index,
}))
);
$effect(() => {
const current = subtasks;
untrack(() => {
const currentIds = new Set(current.map((s) => s.id));
const itemIds = new Set(items.filter((i) => i.id !== SHADOW_PLACEHOLDER_ITEM_ID).map((i) => i.id));
const idsChanged =
currentIds.size !== itemIds.size || current.some((s) => !itemIds.has(s.id));
if (idsChanged) {
items = current.map((s, i) => ({ ...s, order: i }));
dropInProgress = false;
} else if (!dropInProgress) {
const map = new Map(current.map((s) => [s.id, s]));
items = items.map((item) => map.get(item.id) ?? item);
}
});
});
function handleDndConsider(e: CustomEvent<{ items: Subtask[] }>) {
onChange(e.detail.items.map((item, index) => ({ ...item, order: index })));
items = e.detail.items;
}
function handleDndFinalize(e: CustomEvent<{ items: Subtask[] }>) {
onChange(e.detail.items.map((item, index) => ({ ...item, order: index })));
items = e.detail.items.filter((item) => item.id !== SHADOW_PLACEHOLDER_ITEM_ID);
onChange(items.map((item, index) => ({ ...item, order: index })));
dropInProgress = true;
setTimeout(() => { dropInProgress = false; }, 500);
}
function toggleComplete(id: string) {
@ -49,34 +62,24 @@
onChange(subtasks.filter((s) => s.id !== id));
}
function startEditing(subtask: Subtask) {
editingId = subtask.id;
editingTitle = subtask.title;
}
function saveEdit() {
if (!editingId || !editingTitle.trim()) {
cancelEdit();
return;
function handleTitleBlur(e: FocusEvent, subtask: Subtask) {
const el = e.target as HTMLElement;
const trimmed = (el.textContent || '').trim();
if (trimmed && trimmed !== subtask.title) {
onChange(subtasks.map((s) => (s.id === subtask.id ? { ...s, title: trimmed } : s)));
} else {
el.textContent = subtask.title;
}
const updated = subtasks.map((s) =>
s.id === editingId ? { ...s, title: editingTitle.trim() } : s
);
onChange(updated);
cancelEdit();
}
function cancelEdit() {
editingId = null;
editingTitle = '';
}
function handleEditKeydown(e: KeyboardEvent) {
function handleTitleKeydown(e: KeyboardEvent, subtask: Subtask) {
if (e.key === 'Enter') {
e.preventDefault();
saveEdit();
(e.target as HTMLElement).blur();
} else if (e.key === 'Escape') {
cancelEdit();
const el = e.target as HTMLElement;
el.textContent = subtask.title;
el.blur();
}
}
@ -111,7 +114,13 @@
onfinalize={handleDndFinalize}
>
{#each items as subtask (subtask.id)}
<div class="subtask-item" animate:flip={{ duration: 200 }}>
<div
class="subtask-item"
animate:flip={{ duration: 200 }}
onpointerdown={(e) => {
if (!(e.target as HTMLElement).closest('.drag-handle')) e.stopPropagation();
}}
>
<!-- Drag handle -->
<div class="drag-handle" aria-label="Ziehen zum Sortieren">
<DotsSixVertical size={16} />
@ -129,25 +138,17 @@
{/if}
</button>
<!-- Title (editable) -->
{#if editingId === subtask.id}
<input
type="text"
class="subtask-edit-input"
bind:value={editingTitle}
onkeydown={handleEditKeydown}
onblur={saveEdit}
/>
{:else}
<button
type="button"
class="subtask-title"
class:completed={subtask.isCompleted}
ondblclick={() => startEditing(subtask)}
>
{subtask.title}
</button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="subtask-title"
class:completed={subtask.isCompleted}
contenteditable="true"
role="textbox"
spellcheck="false"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => handleTitleKeydown(e, subtask)}
onblur={(e) => handleTitleBlur(e, subtask)}
>{subtask.title}</span>
<!-- Delete button -->
<button
@ -257,13 +258,12 @@
.subtask-title {
flex: 1;
text-align: left;
background: none;
border: none;
padding: 0;
font-size: 0.875rem;
color: var(--color-foreground);
cursor: text;
outline: none;
word-break: break-word;
min-width: 0;
}
.subtask-title.completed {
@ -271,17 +271,6 @@
color: var(--color-muted-foreground);
}
.subtask-edit-input {
flex: 1;
background: var(--color-surface-elevated-3);
border: 1px solid var(--color-primary);
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
color: var(--color-foreground);
outline: none;
}
.subtask-delete {
opacity: 0;
padding: 0.25rem;

View file

@ -153,43 +153,39 @@
}
// Inline title editing
let titleRef = $state<HTMLSpanElement | null>(null);
let inlineTitleInputRef = $state<HTMLInputElement | null>(null);
let isEditingTitle = $state(false);
let editTitleValue = $state('');
function handleTitleKeydown(e: KeyboardEvent) {
function startTitleEdit(e: MouseEvent) {
e.stopPropagation();
editTitleValue = task.title;
isEditingTitle = true;
}
$effect(() => {
if (isEditingTitle && inlineTitleInputRef) {
inlineTitleInputRef.focus();
inlineTitleInputRef.select();
}
});
function handleInlineTitleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
titleRef?.blur();
inlineTitleInputRef?.blur();
} else if (e.key === 'Escape') {
if (titleRef) titleRef.textContent = task.title;
titleRef?.blur();
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
e.preventDefault();
const allTitles = Array.from(
document.querySelectorAll<HTMLElement>('.task-title[contenteditable]')
);
const currentIndex = allTitles.indexOf(titleRef!);
const nextTitle = allTitles[currentIndex + direction];
titleRef?.blur();
if (nextTitle) {
nextTitle.focus();
} else {
// No next/prev todo — focus QuickInputBar
const input = document.querySelector<HTMLInputElement>('.quick-input-bar input');
input?.focus();
}
editTitleValue = task.title;
isEditingTitle = false;
}
}
function handleTitleBlur() {
if (!titleRef) return;
const trimmed = (titleRef.textContent || '').trim();
function handleInlineTitleBlur() {
const trimmed = editTitleValue.trim();
if (trimmed && trimmed !== task.title) {
onSave?.({ title: trimmed });
} else {
// Revert if empty or unchanged
titleRef.textContent = task.title;
}
isEditingTitle = false;
}
function handleContentClick() {
@ -336,20 +332,25 @@
<!-- Content -->
<div class="task-content">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
bind:this={titleRef}
class="task-title"
class:line-through={task.isCompleted}
contenteditable="true"
role="textbox"
spellcheck="true"
onkeydown={handleTitleKeydown}
onblur={handleTitleBlur}
onclick={(e) => e.stopPropagation()}
>
{task.title}
</span>
{#if isEditingTitle}
<input
bind:this={inlineTitleInputRef}
class="task-title-edit"
bind:value={editTitleValue}
onclick={(e) => e.stopPropagation()}
onkeydown={handleInlineTitleKeydown}
onblur={handleInlineTitleBlur}
/>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="task-title"
class:line-through={task.isCompleted}
onclick={startTitleEdit}
>
{task.title}
</span>
{/if}
<!-- Labels below title -->
{#if task.labels && task.labels.length > 0}
@ -746,12 +747,14 @@
cursor: pointer;
border-radius: 0.375rem;
opacity: 0;
pointer-events: none;
transition: all 0.15s;
padding: 0;
}
.task-item:hover .detail-btn {
opacity: 1;
pointer-events: auto;
}
.detail-btn:hover {
@ -888,13 +891,11 @@
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
margin: -0.125rem -0.25rem;
outline: none;
transition: background 0.1s;
user-select: none;
}
.task-title:focus {
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
outline: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
.task-title:hover {
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
}
.task-title.line-through {
@ -902,6 +903,20 @@
color: var(--color-muted-foreground);
}
.task-title-edit {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-foreground);
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
margin: -0.125rem -0.25rem;
outline: none;
width: calc(100% + 0.5rem);
word-break: break-word;
}
/* Meta info */
.task-meta {
display: flex;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Task } from '@todo/shared';
import type { Task, Subtask } from '@todo/shared';
import { isToday, isPast } from 'date-fns';
import { formatDueDate } from '$lib/utils/date-display';
import { getSubtaskProgress } from '$lib/utils/task-helpers';
@ -7,13 +7,18 @@
import TaskEditModal from '../TaskEditModal.svelte';
import {
ArrowsClockwise,
ArrowsOutSimple,
CalendarBlank,
Check,
CheckSquare,
DotsSixVertical,
Note,
Trash,
} from '@manacore/shared-icons';
import { PRIORITY_BG_CLASSES } from '$lib/constants/priority';
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { untrack } from 'svelte';
interface Props {
task: Task;
@ -31,10 +36,7 @@
// Completion animation
let isAnimatingComplete = $state(false);
// Inline edit state
let isEditingTitle = $state(false);
let editTitle = $state('');
let titleInputRef = $state<HTMLInputElement | null>(null);
let titleSpanRef = $state<HTMLElement | null>(null);
// Context menu state
let showContextMenu = $state(false);
@ -61,8 +63,7 @@
// Click to open modal
function handleCardClick(e: MouseEvent) {
// Don't open modal if clicking on checkbox or during inline edit or animation
if (isEditingTitle || isAnimatingComplete) return;
if (isAnimatingComplete) return;
const target = e.target as HTMLElement;
if (target.closest('.task-checkbox')) return;
showModal = true;
@ -82,40 +83,29 @@
}, 500);
}
// Double-click to edit title inline
function handleTitleDoubleClick(e: MouseEvent) {
function handleOpenModal(e: MouseEvent) {
e.stopPropagation();
editTitle = task.title;
isEditingTitle = true;
// Focus input after render
setTimeout(() => {
titleInputRef?.focus();
titleInputRef?.select();
}, 0);
showModal = true;
}
// Save inline title edit
function saveInlineTitle() {
if (editTitle.trim() && editTitle.trim() !== task.title) {
onSave?.({ title: editTitle.trim() });
function handleTitleBlur(e: FocusEvent) {
const el = e.target as HTMLElement;
const trimmed = (el.textContent || '').trim();
if (trimmed && trimmed !== task.title) {
onSave?.({ title: trimmed });
} else {
el.textContent = task.title;
}
isEditingTitle = false;
}
// Cancel inline title edit
function cancelInlineTitle() {
isEditingTitle = false;
editTitle = '';
}
// Handle title input keydown
function handleTitleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
saveInlineTitle();
(e.target as HTMLElement).blur();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelInlineTitle();
const el = e.target as HTMLElement;
el.textContent = task.title;
el.blur();
}
}
@ -168,6 +158,37 @@
showModal = false;
}
// Inline subtask DnD state
let subtaskItems = $state<Subtask[]>([]);
let subtaskDropInProgress = false;
$effect(() => {
const current = task.subtasks ?? [];
untrack(() => {
const newIds = new Set(current.map((s) => s.id));
const oldIds = new Set(subtaskItems.filter((s) => s.id !== SHADOW_PLACEHOLDER_ITEM_ID).map((s) => s.id));
const idsChanged = newIds.size !== oldIds.size || current.some((s) => !oldIds.has(s.id));
if (idsChanged) {
subtaskItems = [...current];
subtaskDropInProgress = false;
} else if (!subtaskDropInProgress) {
const map = new Map(current.map((s) => [s.id, s]));
subtaskItems = subtaskItems.map((item) => map.get(item.id) ?? item);
}
});
});
function handleSubtaskConsider(e: CustomEvent<{ items: Subtask[] }>) {
subtaskItems = e.detail.items;
}
function handleSubtaskFinalize(e: CustomEvent<{ items: Subtask[] }>) {
subtaskItems = e.detail.items.filter((s) => s.id !== SHADOW_PLACEHOLDER_ITEM_ID);
onSave?.({ subtasks: subtaskItems });
subtaskDropInProgress = true;
setTimeout(() => { subtaskDropInProgress = false; }, 500);
}
function toggleSubtask(subtaskId: string) {
if (!onSave) return;
const subtasks = $state.snapshot(task.subtasks) ?? [];
@ -213,24 +234,18 @@
<!-- Content -->
<div class="task-content">
{#if isEditingTitle}
<input
bind:this={titleInputRef}
type="text"
class="title-input"
bind:value={editTitle}
onkeydown={handleTitleKeydown}
onblur={saveInlineTitle}
/>
{:else}
<span
class="task-title"
class:line-through={task.isCompleted || isAnimatingComplete}
ondblclick={handleTitleDoubleClick}
>
{task.title}
</span>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
bind:this={titleSpanRef}
class="task-title"
class:line-through={task.isCompleted || isAnimatingComplete}
contenteditable={!task.isCompleted && !isAnimatingComplete}
role="textbox"
spellcheck="false"
onclick={(e) => e.stopPropagation()}
onkeydown={handleTitleKeydown}
onblur={handleTitleBlur}
>{task.title}</span>
<!-- Meta info -->
{#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)}
@ -263,6 +278,17 @@
{/if}
</div>
<!-- Detail modal button -->
<button
type="button"
class="detail-btn"
onclick={handleOpenModal}
title="Details öffnen"
tabindex="-1"
>
<ArrowsOutSimple size={14} />
</button>
<!-- Contacts display -->
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
<div class="contacts-display">
@ -292,23 +318,38 @@
</div>
<!-- Inline subtasks — shown while incomplete; during animation all appear as done -->
{#if task.subtasks && task.subtasks.length > 0 && !task.isCompleted}
{#if subtaskItems.length > 0 && !task.isCompleted}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="subtasks-inline" onpointerdown={(e) => e.stopPropagation()}>
{#each task.subtasks as subtask (subtask.id)}
<button
<div
class="subtasks-inline"
use:dndzone={{ items: subtaskItems, flipDurationMs: 150, dropTargetStyle: {}, type: 'subtask-inline' }}
onconsider={handleSubtaskConsider}
onfinalize={handleSubtaskFinalize}
>
{#each subtaskItems.filter((s) => s.id !== SHADOW_PLACEHOLDER_ITEM_ID) as subtask (subtask.id)}
<div
class="subtask-row"
class:done={subtask.isCompleted || isAnimatingComplete}
onclick={(e) => {
e.stopPropagation();
if (!isAnimatingComplete) toggleSubtask(subtask.id);
animate:flip={{ duration: 150 }}
onpointerdown={(e) => {
if (!(e.target as HTMLElement).closest('.subtask-drag-handle')) e.stopPropagation();
}}
>
<span class="subtask-check" class:checked={subtask.isCompleted || isAnimatingComplete}>
{#if subtask.isCompleted || isAnimatingComplete}<Check size={10} />{/if}
</span>
<span class="subtask-drag-handle"><DotsSixVertical size={10} /></span>
<button
class="subtask-check-btn"
class:checked={subtask.isCompleted || isAnimatingComplete}
onclick={(e) => {
e.stopPropagation();
if (!isAnimatingComplete) toggleSubtask(subtask.id);
}}
>
<span class="subtask-check" class:checked={subtask.isCompleted || isAnimatingComplete}>
{#if subtask.isCompleted || isAnimatingComplete}<Check size={10} />{/if}
</span>
</button>
<span class="subtask-title">{subtask.title}</span>
</button>
</div>
{/each}
</div>
{/if}
@ -455,6 +496,37 @@
color: white;
}
/* Detail modal button */
.detail-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: none;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
border-radius: 0.375rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
padding: 0;
margin-top: 0.1rem;
}
.kanban-card:hover .detail-btn {
opacity: 0.6;
pointer-events: auto;
}
.detail-btn:hover {
opacity: 1 !important;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
/* Content */
.task-content {
flex: 1;
@ -468,9 +540,22 @@
font-size: 0.9375rem;
font-weight: 400;
color: hsl(var(--color-foreground));
cursor: pointer;
cursor: text;
line-height: 1.4;
word-break: break-word;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
margin: -0.125rem -0.25rem;
transition: background 0.1s;
outline: none;
}
.task-title:hover {
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
}
.task-title:focus {
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
}
:global(.dark) .task-title {
@ -482,24 +567,6 @@
opacity: 0.5;
}
/* Inline title input */
.title-input {
width: 100%;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #8b5cf6;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
outline: none;
}
:global(.dark) .title-input {
background: rgba(30, 30, 30, 0.9);
color: #f3f4f6;
border-color: #8b5cf6;
}
/* Meta info */
.task-meta {
@ -670,13 +737,36 @@
.subtask-row {
display: flex;
align-items: flex-start;
gap: 0.625rem;
gap: 0.375rem;
padding: 0.15rem 0;
width: 100%;
}
.subtask-drag-handle {
display: flex;
align-items: center;
color: var(--color-muted-foreground);
opacity: 0;
cursor: grab;
flex-shrink: 0;
margin-top: 0.2rem;
transition: opacity 0.15s;
}
.subtask-row:hover .subtask-drag-handle {
opacity: 0.5;
}
.subtask-drag-handle:hover {
opacity: 1 !important;
}
.subtask-check-btn {
background: transparent;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
width: 100%;
flex-shrink: 0;
}
.subtask-check {
@ -688,7 +778,6 @@
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
color: white;
margin-top: 0.2rem;
@ -698,7 +787,7 @@
border-color: rgba(255, 255, 255, 0.35);
}
.subtask-check:hover {
.subtask-check-btn:hover .subtask-check {
border-color: #8b5cf6;
}
@ -708,6 +797,7 @@
}
.subtask-title {
flex: 1;
font-size: 0.9375rem;
font-weight: 400;
color: hsl(var(--color-foreground));
@ -715,10 +805,6 @@
word-break: break-word;
}
:global(.dark) .subtask-title {
color: hsl(var(--color-foreground));
}
.subtask-row.done .subtask-title {
text-decoration: line-through;
opacity: 0.45;