mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 06:29:40 +02:00
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:
parent
16057964a6
commit
3f0811043e
6 changed files with 522 additions and 612 deletions
|
|
@ -158,7 +158,6 @@
|
|||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<main class="relative z-0 pb-24" style="padding-top: 0">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue