Merge pull request #19 from SteelDynamite/feat/subtask-ux-overhaul
feat/subtask-ux-overhaul
This commit is contained in:
commit
0294711d1d
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -48,12 +48,14 @@ Workspaces are plain folders. Each task list is a subfolder containing `.listdat
|
|||
|
||||
The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). Key UI patterns:
|
||||
|
||||
- **Sliding drawer**: Left panel (lists) slides with main content as one piece via `translateX`. 80vw wide.
|
||||
- **Sliding drawer**: Left panel (lists) slides with main content as one piece via `translateX`. 80vw wide. List items show checkmark for active list and chevron on hover.
|
||||
- **Three-panel slide**: Main content area is 300% wide with three panels (task list, task detail, subtask detail) that slide via `translateX` using a `taskStack` array. Stack depth 0 = list, 1 = task detail, 2 = subtask detail.
|
||||
- **Settings popup**: Floating overlay card with backdrop, not a sliding panel.
|
||||
- **Workspace switcher**: Custom drop-up menu in drawer footer (left), settings gear (right).
|
||||
- **Task animations**: Grid-rows `0fr`/`1fr` trick for smooth collapse/expand. Module-level `animateInIds` Set coordinates expand-in after toggle.
|
||||
- **Inline editing**: Click task to edit, auto-save on blur, shared `editingTaskId` across instances.
|
||||
- **Kebab menus**: Tasks, lists, and workspaces all use kebab → submenu pattern for delete.
|
||||
- **Inline editing**: Click task to edit, auto-save on blur. `debouncedSave` snapshots task before timer to prevent stale-reference errors on component destroy.
|
||||
- **Kebab menus**: Tasks and lists use kebab menus with custom `ConfirmDialog` component (not native `confirm()`). "Move to..." is inline in the menu (not a submenu) to avoid overflow.
|
||||
- **Main panel header**: Hamburger + window controls in top bar; list name (large, bold) + kebab below divider (matching task detail layout). Kebab has Rename, Group by due date, Delete completed, Delete list.
|
||||
- **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning).
|
||||
|
||||
### Current state (2026-04-01)
|
||||
|
|
@ -75,10 +77,11 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
|
|||
- Dark mode (GNOME-style neutral grays, cyan-blue accent)
|
||||
- Completed tasks section with animated show/hide
|
||||
- Due date picker/editor (DateTimePicker in new task + task detail); `has_time: bool` field tracks whether time is set
|
||||
- Move task between lists (kebab menu → "Move to..." submenu)
|
||||
- List rename (inline input via list kebab menu)
|
||||
- Group-by-due-date toggle per list (list kebab menu)
|
||||
- Keyboard shortcuts (Escape priority chain: settings → detail → drawer → menus)
|
||||
- Move task between lists (inline list in kebab menu, no submenu)
|
||||
- List rename (inline input in main panel header via kebab)
|
||||
- Group-by-due-date toggle per list (main panel kebab)
|
||||
- Delete completed tasks (main panel kebab + subtask kebab, with confirmation dialogs)
|
||||
- Keyboard shortcuts (Escape priority chain: settings → detail → list menu → drawer → menus)
|
||||
- WebDAV setup flow (settings auto-populates URL/credentials from config + keychain)
|
||||
- File watcher (notify crate, 500ms debounce, auto-reloads on external changes)
|
||||
- Setup screen with window dragging + "Open Existing Folder" option
|
||||
|
|
@ -87,7 +90,8 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
|
|||
- Desktop packaging (Linux: AppImage + .deb)
|
||||
- Flutter GUI at full parity with Tauri (WebDAV UI, has_time, sync status, sync mode)
|
||||
- Tauri desktop-only deps (notify, keyring) feature-gated for Android compilation
|
||||
- Subtask hierarchy (expand/collapse in task list, inline add in detail view, cascade toggle/delete)
|
||||
- Subtask hierarchy: subtask count shown on parent tasks in list, subtask detail via three-panel slide navigation, inline add at top of subtask list (new subtasks prepend), collapsible completed subtasks section, cascade delete (parent deletion removes all subtasks with confirmation warning)
|
||||
- Custom confirmation dialogs (ConfirmDialog component replaces native confirm())
|
||||
|
||||
### GUI features NOT yet done
|
||||
|
||||
|
|
|
|||
37
apps/tauri/src/lib/components/ConfirmDialog.svelte
Normal file
37
apps/tauri/src/lib/components/ConfirmDialog.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
let { message, detail, confirmText = "Confirm", danger = false, onconfirm, oncancel }:
|
||||
{ message: string; detail?: string; confirmText?: string; danger?: boolean; onconfirm: () => void; oncancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 z-50 flex items-center justify-center"
|
||||
onclick={oncancel}
|
||||
onkeydown={(e) => { if (e.key === "Escape") { e.stopPropagation(); oncancel(); } }}
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40"></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative z-10 mx-6 w-full max-w-sm rounded-xl border border-border-light bg-surface-light p-5 shadow-xl dark:border-border-dark dark:bg-surface-dark"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p class="text-sm font-medium">{message}</p>
|
||||
{#if detail}
|
||||
<p class="mt-2 text-xs opacity-50">{detail}</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onclick={oncancel}
|
||||
class="rounded-lg px-4 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onconfirm}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-white {danger ? 'bg-danger hover:bg-danger/80' : 'bg-primary hover:bg-primary/80'}"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import type { Task } from "../types";
|
||||
import { app } from "../stores/app.svelte";
|
||||
import DateTimePicker from "./DateTimePicker.svelte";
|
||||
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
||||
|
|
@ -9,15 +10,19 @@
|
|||
const currentPlatform = platform();
|
||||
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
|
||||
|
||||
let { task, onback }: { task: Task; onback: () => void } = $props();
|
||||
let { task, onback, onopen }: { task: Task; onback: () => void; onopen?: (task: Task) => void } = $props();
|
||||
|
||||
let title = $state(task.title);
|
||||
let description = $state(task.description);
|
||||
let showMenu = $state(false);
|
||||
let showMoveSubmenu = $state(false);
|
||||
let menuEl = $state<HTMLDivElement | null>(null);
|
||||
let showDatePicker = $state(false);
|
||||
let saveTimer: ReturnType<typeof setTimeout>;
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
return () => clearTimeout(saveTimer);
|
||||
});
|
||||
|
||||
let otherLists = $derived(app.lists.filter((l) => l.id !== app.activeListId));
|
||||
|
||||
|
|
@ -29,8 +34,9 @@
|
|||
|
||||
function debouncedSave(fields: Partial<Task>) {
|
||||
clearTimeout(saveTimer);
|
||||
var snapshot = { ...task };
|
||||
saveTimer = setTimeout(() => {
|
||||
app.updateTask({ ...task, ...fields, updated_at: new Date().toISOString() });
|
||||
app.updateTask({ ...snapshot, ...fields, updated_at: new Date().toISOString() });
|
||||
}, 400);
|
||||
}
|
||||
|
||||
|
|
@ -51,17 +57,22 @@
|
|||
onback();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
function promptDelete() {
|
||||
showMenu = false;
|
||||
if (!confirm(`Delete task "${task.title}"?`)) return;
|
||||
confirmDelete = true;
|
||||
}
|
||||
|
||||
async function executeDelete() {
|
||||
confirmDelete = false;
|
||||
// Cascade: delete subtasks first
|
||||
for (const s of subtasks) await app.deleteTask(s.id);
|
||||
await app.deleteTask(task.id);
|
||||
onback();
|
||||
}
|
||||
|
||||
function handleMenuClickOutside(e: MouseEvent) {
|
||||
if (showMenu && menuEl && !menuEl.contains(e.target as Node)) {
|
||||
if (showMenu && menuEl && !menuEl.contains(e.target as Node))
|
||||
showMenu = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -72,17 +83,42 @@
|
|||
});
|
||||
|
||||
let isCompleted = $derived(task.status === "completed");
|
||||
let isSubtask = $derived(!!task.parent_id);
|
||||
let subtasks = $derived(app.getSubtasks(task.id));
|
||||
let pendingSubtasks = $derived(subtasks.filter(s => s.status !== "completed"));
|
||||
let completedSubtasks = $derived(subtasks.filter(s => s.status === "completed"));
|
||||
let addingSubtask = $state(false);
|
||||
let subtaskTitle = $state("");
|
||||
let showSubtaskMenu = $state(false);
|
||||
let subtaskMenuEl = $state<HTMLDivElement | null>(null);
|
||||
let showCompletedSubtasks = $state(false);
|
||||
let completedSubtasksVisible = $state(false);
|
||||
let confirmDeleteCompleted = $state(false);
|
||||
|
||||
async function handleAddSubtask() {
|
||||
if (!subtaskTitle.trim()) return;
|
||||
await app.createTask(subtaskTitle.trim(), undefined, task.id);
|
||||
subtaskTitle = "";
|
||||
addingSubtask = false;
|
||||
}
|
||||
|
||||
async function executeDeleteCompletedSubtasks() {
|
||||
confirmDeleteCompleted = false;
|
||||
showSubtaskMenu = false;
|
||||
for (const s of completedSubtasks) await app.deleteTask(s.id);
|
||||
}
|
||||
|
||||
function handleSubtaskMenuClickOutside(e: MouseEvent) {
|
||||
if (showSubtaskMenu && subtaskMenuEl && !subtaskMenuEl.contains(e.target as Node))
|
||||
showSubtaskMenu = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showSubtaskMenu) {
|
||||
window.addEventListener("mousedown", handleSubtaskMenuClickOutside);
|
||||
return () => window.removeEventListener("mousedown", handleSubtaskMenuClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
function formatDateChip(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const today = new Date();
|
||||
|
|
@ -139,36 +175,8 @@
|
|||
</svg>
|
||||
{isCompleted ? "Restore task" : "Mark as completed"}
|
||||
</button>
|
||||
{#if otherLists.length > 0}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showMoveSubmenu = !showMoveSubmenu)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
Move to...
|
||||
<svg class="ml-auto h-3 w-3 opacity-40" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if showMoveSubmenu}
|
||||
<div class="absolute left-full top-0 z-50 ml-1 min-w-[160px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
|
||||
{#each otherLists as list}
|
||||
<button
|
||||
onclick={async () => { showMenu = false; showMoveSubmenu = false; await app.moveTask(task.id, list.id); onback(); }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
{list.title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
onclick={promptDelete}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
|
|
@ -176,6 +184,21 @@
|
|||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{#if otherLists.length > 0}
|
||||
<div class="my-1 border-t border-border-light dark:border-border-dark"></div>
|
||||
<p class="px-3 py-1.5 text-xs font-medium opacity-40">Move to...</p>
|
||||
{#each otherLists as list}
|
||||
<button
|
||||
onclick={async () => { showMenu = false; await app.moveTask(task.id, list.id); onback(); }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4 opacity-40" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h8a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" />
|
||||
</svg>
|
||||
{list.title}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -236,58 +259,131 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Subtasks section -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="h-5 w-5 shrink-0 opacity-40" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm2 4a1 1 0 011-1h10a1 1 0 110 2H6a1 1 0 01-1-1zm2 4a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium opacity-60">Subtasks{subtasks.length > 0 ? ` (${subtasks.filter(s => s.status === "completed").length}/${subtasks.length})` : ""}</span>
|
||||
</div>
|
||||
{#each subtasks as subtask (subtask.id)}
|
||||
<button
|
||||
onclick={() => onback()}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={(e) => { e.stopPropagation(); app.toggleTask(subtask.id); }}
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 {subtask.status === 'completed' ? 'border-primary bg-primary' : 'border-gray-400 dark:border-gray-500'}"
|
||||
>
|
||||
{#if subtask.status === "completed"}
|
||||
<svg class="h-2.5 w-2.5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-sm {subtask.status === 'completed' ? 'line-through opacity-50' : ''}">{subtask.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if addingSubtask}
|
||||
<div class="flex items-center gap-2 px-2 py-1">
|
||||
<div class="h-4 w-4 shrink-0 rounded-full border-2 border-gray-400 dark:border-gray-500"></div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={subtaskTitle}
|
||||
placeholder="Subtask title"
|
||||
class="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleAddSubtask(); if (e.key === "Escape") { addingSubtask = false; subtaskTitle = ""; } }}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (addingSubtask = true)}
|
||||
class="flex w-full items-center gap-2 px-2 py-2 text-sm text-primary opacity-60 hover:opacity-100"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" />
|
||||
<!-- Subtasks section (only for top-level tasks) -->
|
||||
{#if !isSubtask}
|
||||
<div class="mt-6 border-t border-border-light pt-4 dark:border-border-dark">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="h-5 w-5 shrink-0 opacity-40" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm2 4a1 1 0 011-1h10a1 1 0 110 2H6a1 1 0 01-1-1zm2 4a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1z" />
|
||||
</svg>
|
||||
Add subtask
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-sm font-medium opacity-60">Subtasks{subtasks.length > 0 ? ` (${completedSubtasks.length}/${subtasks.length})` : ""}</span>
|
||||
<!-- Subtasks kebab menu -->
|
||||
{#if completedSubtasks.length > 0}
|
||||
<div class="relative ml-auto" bind:this={subtaskMenuEl}>
|
||||
<button
|
||||
onclick={() => (showSubtaskMenu = !showSubtaskMenu)}
|
||||
class="rounded p-1 opacity-40 hover:opacity-70"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if showSubtaskMenu}
|
||||
<div class="absolute right-0 top-full z-40 mt-1 min-w-[240px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
|
||||
<button
|
||||
onclick={() => { showSubtaskMenu = false; confirmDeleteCompleted = true; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Delete completed subtasks
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Add subtask (top of list) -->
|
||||
{#if addingSubtask}
|
||||
<div class="flex items-center gap-2 px-2 py-1">
|
||||
<div class="h-4 w-4 shrink-0 rounded-full border-2 border-gray-400 dark:border-gray-500"></div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={subtaskTitle}
|
||||
placeholder="Subtask title"
|
||||
class="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleAddSubtask(); if (e.key === "Escape") { e.stopPropagation(); addingSubtask = false; subtaskTitle = ""; } }}
|
||||
onblur={async () => { if (subtaskTitle.trim()) { await handleAddSubtask(); addingSubtask = false; } else { addingSubtask = false; subtaskTitle = ""; } }}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (addingSubtask = true)}
|
||||
class="flex w-full items-center gap-2 px-2 py-2 text-sm text-primary opacity-60 hover:opacity-100"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" />
|
||||
</svg>
|
||||
Add subtask
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Pending subtasks -->
|
||||
{#each pendingSubtasks as subtask (subtask.id)}
|
||||
<button
|
||||
onclick={() => onopen?.(subtask)}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={(e) => { e.stopPropagation(); app.toggleTask(subtask.id); }}
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-gray-400 dark:border-gray-500"
|
||||
>
|
||||
</div>
|
||||
<span class="text-sm">{subtask.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Completed subtasks (collapsible) -->
|
||||
{#if completedSubtasks.length > 0}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (showCompletedSubtasks) {
|
||||
showCompletedSubtasks = false;
|
||||
setTimeout(() => (completedSubtasksVisible = false), 200);
|
||||
} else {
|
||||
completedSubtasksVisible = true;
|
||||
requestAnimationFrame(() => (showCompletedSubtasks = true));
|
||||
}
|
||||
}}
|
||||
class="mt-2 flex w-full items-center gap-2 rounded-lg px-2 py-2 text-sm opacity-50 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform {showCompletedSubtasks ? 'rotate-90' : ''}"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
|
||||
</svg>
|
||||
Completed ({completedSubtasks.length})
|
||||
</button>
|
||||
{#if completedSubtasksVisible}
|
||||
<div class="transition-all duration-200 ease-out {showCompletedSubtasks ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'}">
|
||||
{#each completedSubtasks as subtask (subtask.id)}
|
||||
<button
|
||||
onclick={() => onopen?.(subtask)}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={(e) => { e.stopPropagation(); app.toggleTask(subtask.id); }}
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-primary"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm line-through opacity-50">{subtask.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Date picker overlay -->
|
||||
|
|
@ -299,3 +395,26 @@
|
|||
onclose={() => (showDatePicker = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
{#if confirmDelete}
|
||||
<ConfirmDialog
|
||||
message='Delete task "{task.title}"?'
|
||||
detail={subtasks.length > 0 ? `This will also delete ${subtasks.length} subtask${subtasks.length === 1 ? '' : 's'}.` : undefined}
|
||||
confirmText="Delete"
|
||||
danger
|
||||
onconfirm={executeDelete}
|
||||
oncancel={() => (confirmDelete = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Delete completed subtasks confirmation -->
|
||||
{#if confirmDeleteCompleted}
|
||||
<ConfirmDialog
|
||||
message="Delete {completedSubtasks.length} completed subtask{completedSubtasks.length === 1 ? '' : 's'}?"
|
||||
confirmText="Delete"
|
||||
danger
|
||||
onconfirm={executeDeleteCompletedSubtasks}
|
||||
oncancel={() => (confirmDeleteCompleted = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import TaskItem from "../components/TaskItem.svelte";
|
||||
import TaskDetailView from "../components/TaskDetailView.svelte";
|
||||
import NewTaskInput, { newTaskState } from "../components/NewTaskInput.svelte";
|
||||
import ConfirmDialog from "../components/ConfirmDialog.svelte";
|
||||
import SettingsScreen from "./SettingsScreen.svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
|
@ -13,15 +14,23 @@
|
|||
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
|
||||
const isWindows = currentPlatform === "windows";
|
||||
|
||||
let selectedTaskId = $state<string | null>(null);
|
||||
let selectedTask = $derived(selectedTaskId ? app.tasks.find(t => t.id === selectedTaskId) ?? null : null);
|
||||
let taskStack = $state<string[]>([]);
|
||||
let parentTask = $derived(taskStack.length >= 1 ? app.tasks.find(t => t.id === taskStack[0]) ?? null : null);
|
||||
let subtaskDetail = $derived(taskStack.length >= 2 ? app.tasks.find(t => t.id === taskStack[1]) ?? null : null);
|
||||
|
||||
function openTask(task: Task) {
|
||||
selectedTaskId = task.id;
|
||||
taskStack = [task.id];
|
||||
}
|
||||
|
||||
function pushTask(task: Task) {
|
||||
taskStack = [...taskStack, task.id];
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
selectedTaskId = null;
|
||||
if (taskStack.length > 1)
|
||||
taskStack = taskStack.slice(0, -1);
|
||||
else
|
||||
taskStack = [];
|
||||
}
|
||||
|
||||
let showDrawer = $state(false);
|
||||
|
|
@ -31,22 +40,24 @@
|
|||
let workspacePickerEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
if (showWorkspacePicker && workspacePickerEl && !workspacePickerEl.contains(e.target as Node)) {
|
||||
if (showWorkspacePicker && workspacePickerEl && !workspacePickerEl.contains(e.target as Node))
|
||||
showWorkspacePicker = false;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (listMenuId && !target.closest("[data-list-menu]")) listMenuId = null;
|
||||
if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node))
|
||||
showListMenu = false;
|
||||
var target = e.target as HTMLElement;
|
||||
if (wsMenuName && !target.closest("[data-ws-menu]")) wsMenuName = null;
|
||||
}
|
||||
|
||||
let newListName = $state("");
|
||||
let showCompleted = $state(false);
|
||||
let completedVisible = $state(false);
|
||||
let listMenuId = $state<string | null>(null);
|
||||
let wsMenuName = $state<string | null>(null);
|
||||
let renamingListId = $state<string | null>(null);
|
||||
let renameValue = $state("");
|
||||
let expandedTasks = $state(new Set<string>());
|
||||
let showListMenu = $state(false);
|
||||
let listMenuEl = $state<HTMLDivElement | null>(null);
|
||||
let confirmDeleteList = $state(false);
|
||||
let confirmDeleteCompleted = $state(false);
|
||||
let dragId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
let resizing = $state(false);
|
||||
|
|
@ -73,43 +84,57 @@
|
|||
showNewList = false;
|
||||
}
|
||||
|
||||
async function handleDeleteList(id: string) {
|
||||
listMenuId = null;
|
||||
const list = app.lists.find(l => l.id === id);
|
||||
if (!confirm(`Delete list "${list?.title ?? id}" and all its tasks?`)) return;
|
||||
await app.deleteList(id);
|
||||
function promptDeleteCompleted() {
|
||||
showListMenu = false;
|
||||
confirmDeleteCompleted = true;
|
||||
}
|
||||
|
||||
function startRenameList(id: string) {
|
||||
listMenuId = null;
|
||||
const list = app.lists.find(l => l.id === id);
|
||||
async function executeDeleteCompleted() {
|
||||
confirmDeleteCompleted = false;
|
||||
for (var t of app.completedTasks) await app.deleteTask(t.id);
|
||||
}
|
||||
|
||||
function promptDeleteList() {
|
||||
showListMenu = false;
|
||||
confirmDeleteList = true;
|
||||
}
|
||||
|
||||
async function executeDeleteList() {
|
||||
confirmDeleteList = false;
|
||||
if (app.activeListId) await app.deleteList(app.activeListId);
|
||||
}
|
||||
|
||||
function startRenameList() {
|
||||
showListMenu = false;
|
||||
if (!app.activeListId) return;
|
||||
var list = app.lists.find(l => l.id === app.activeListId);
|
||||
if (!list) return;
|
||||
renamingListId = id;
|
||||
renamingListId = app.activeListId;
|
||||
renameValue = list.title;
|
||||
}
|
||||
|
||||
async function handleRenameList() {
|
||||
if (!renamingListId || !renameValue.trim()) { renamingListId = null; return; }
|
||||
const list = app.lists.find(l => l.id === renamingListId);
|
||||
if (renameValue.trim() !== list?.title) {
|
||||
var list = app.lists.find(l => l.id === renamingListId);
|
||||
if (renameValue.trim() !== list?.title)
|
||||
await app.renameList(renamingListId, renameValue.trim());
|
||||
}
|
||||
renamingListId = null;
|
||||
}
|
||||
|
||||
async function handleToggleGroupByDueDate(id: string) {
|
||||
listMenuId = null;
|
||||
const list = app.lists.find(l => l.id === id);
|
||||
async function handleToggleGroupByDueDate() {
|
||||
showListMenu = false;
|
||||
if (!app.activeListId) return;
|
||||
var list = app.lists.find(l => l.id === app.activeListId);
|
||||
if (!list) return;
|
||||
await app.setGroupByDueDate(id, !list.group_by_due_date);
|
||||
await app.setGroupByDueDate(app.activeListId, !list.group_by_due_date);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
if (showSettings) { showSettings = false; return; }
|
||||
if (selectedTaskId) { selectedTaskId = null; return; }
|
||||
if (taskStack.length > 0) { closeDetail(); return; }
|
||||
if (showListMenu) { showListMenu = false; return; }
|
||||
if (showDrawer) { closeDrawer(); return; }
|
||||
if (listMenuId) { listMenuId = null; return; }
|
||||
if (wsMenuName) { wsMenuName = null; return; }
|
||||
if (showWorkspacePicker) { showWorkspacePicker = false; return; }
|
||||
}
|
||||
|
|
@ -164,7 +189,6 @@
|
|||
function closeDrawer() {
|
||||
showDrawer = false;
|
||||
showNewList = false;
|
||||
listMenuId = null;
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
|
|
@ -276,78 +300,20 @@
|
|||
<!-- List items + new list button -->
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
{#each app.lists as list (list.id)}
|
||||
<div class="group relative flex items-center px-2 hover:bg-black/5 dark:hover:bg-white/10">
|
||||
{#if renamingListId === list.id}
|
||||
<div class="flex flex-1 items-center px-3 py-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={renameValue}
|
||||
class="w-full rounded border border-primary bg-transparent px-2 py-1.5 text-sm outline-none"
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleRenameList(); if (e.key === "Escape") renamingListId = null; }}
|
||||
onblur={handleRenameList}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { app.selectList(list.id); closeDrawer(); }}
|
||||
class="flex flex-1 items-center gap-2 px-3 py-2.5 text-left text-sm {list.id === app.activeListId ? 'font-bold' : ''}"
|
||||
>
|
||||
{#if list.id === app.activeListId}
|
||||
<svg class="h-4 w-4 shrink-0 opacity-50" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{list.title}</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { app.selectList(list.id); closeDrawer(); }}
|
||||
class="group flex w-full items-center gap-2 px-5 py-2.5 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10 {list.id === app.activeListId ? 'font-bold' : ''}"
|
||||
>
|
||||
{#if list.id === app.activeListId}
|
||||
<svg class="h-4 w-4 shrink-0 opacity-50" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
<div class="relative shrink-0" data-list-menu>
|
||||
<button
|
||||
onclick={() => (listMenuId = listMenuId === list.id ? null : list.id)}
|
||||
class="rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80 {listMenuId === list.id ? '!opacity-80' : ''}"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if listMenuId === list.id}
|
||||
<div class="absolute right-0 top-full z-40 mt-1 min-w-[180px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
|
||||
<button
|
||||
onclick={() => startRenameList(list.id)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleToggleGroupByDueDate(list.id)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Group by due date
|
||||
{#if list.group_by_due_date}
|
||||
<svg class="ml-auto h-4 w-4 text-primary" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDeleteList(list.id)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-1">{list.title}</span>
|
||||
<svg class="h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-30" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- New list inline -->
|
||||
|
|
@ -410,10 +376,10 @@
|
|||
<!-- Sliding inner: task list + detail view -->
|
||||
<div
|
||||
class="flex h-full {resizing ? '' : 'transition-transform duration-250'} ease-out"
|
||||
style="width: 200%; transform: translateX({selectedTask ? '-50%' : '0'})"
|
||||
style="width: 300%; transform: translateX({taskStack.length === 0 ? '0' : taskStack.length === 1 ? '-33.333%' : '-66.666%'})"
|
||||
>
|
||||
<!-- Sub-panel: Task list -->
|
||||
<div class="relative flex h-full w-1/2 flex-col">
|
||||
<div class="relative flex h-full w-1/3 flex-col">
|
||||
<!-- Header / drag region -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<header
|
||||
|
|
@ -423,21 +389,18 @@
|
|||
<!-- Drawer toggle (left) -->
|
||||
<button
|
||||
onclick={() => (showDrawer = !showDrawer)}
|
||||
class="absolute left-2 rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-5 w-5 opacity-60" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h8a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Centered title -->
|
||||
<div class="flex-1 text-center">
|
||||
<p class="text-sm font-semibold leading-tight">{app.activeList?.title ?? "Tasks"}</p>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Window controls (right) -->
|
||||
{#if isDesktop}
|
||||
<div class="absolute right-1.5 flex items-center gap-0.5">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#if isWindows}
|
||||
<button
|
||||
onclick={() => appWindow.minimize()}
|
||||
|
|
@ -460,6 +423,82 @@
|
|||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- List name + kebab (below header bar, like task detail) -->
|
||||
<div class="relative px-4 pt-3 pb-2">
|
||||
{#if app.activeListId}
|
||||
<!-- Kebab menu -->
|
||||
<div class="absolute right-3 top-1" bind:this={listMenuEl}>
|
||||
<button
|
||||
onclick={() => (showListMenu = !showListMenu)}
|
||||
class="rounded-lg p-1.5 opacity-50 hover:bg-black/5 hover:opacity-80 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if showListMenu}
|
||||
<div class="absolute right-0 top-full z-40 mt-1 min-w-[200px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
|
||||
<button
|
||||
onclick={startRenameList}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onclick={handleToggleGroupByDueDate}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Group by due date
|
||||
{#if app.activeList?.group_by_due_date}
|
||||
<svg class="ml-auto h-4 w-4 text-primary" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if app.completedTasks.length > 0}
|
||||
<button
|
||||
onclick={promptDeleteCompleted}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Delete completed
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={promptDeleteList}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Delete list
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if renamingListId === app.activeListId}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={renameValue}
|
||||
class="w-full bg-transparent text-xl font-bold outline-none"
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleRenameList(); if (e.key === "Escape") renamingListId = null; }}
|
||||
onblur={handleRenameList}
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xl font-bold">{app.activeList?.title ?? "Tasks"}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
{#if app.lists.length === 0}
|
||||
|
|
@ -482,33 +521,8 @@
|
|||
ondrop={(e) => handleDrop(e, task.id)}
|
||||
class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}"
|
||||
>
|
||||
{#if app.getSubtasks(task.id).length > 0}
|
||||
<!-- Parent with subtasks: show expand/collapse toggle -->
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); expandedTasks.has(task.id) ? expandedTasks.delete(task.id) : expandedTasks.add(task.id); expandedTasks = new Set(expandedTasks); }}
|
||||
class="flex h-full shrink-0 items-center pl-2 pr-0 py-3 opacity-40 hover:opacity-70"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5 transition-transform {expandedTasks.has(task.id) ? 'rotate-90' : ''}" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<TaskItem {task} onopen={openTask} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TaskItem {task} onopen={openTask} />
|
||||
{/if}
|
||||
<TaskItem {task} onopen={openTask} />
|
||||
</div>
|
||||
<!-- Render subtasks if expanded -->
|
||||
{#if expandedTasks.has(task.id)}
|
||||
{#each app.getSubtasks(task.id) as subtask (subtask.id)}
|
||||
<div class="border-l-2 border-border-light ml-4 dark:border-border-dark">
|
||||
<TaskItem task={subtask} onopen={openTask} depth={1} />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if app.pendingTasks.length === 0}
|
||||
|
|
@ -545,11 +559,6 @@
|
|||
<div class="transition-all duration-300 ease-out {showCompleted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}">
|
||||
{#each app.completedTasks as task (task.id)}
|
||||
<TaskItem {task} onopen={openTask} />
|
||||
{#each app.getSubtasks(task.id).filter(s => s.status === "completed") as subtask (subtask.id)}
|
||||
<div class="border-l-2 border-border-light ml-4 dark:border-border-dark">
|
||||
<TaskItem task={subtask} onopen={openTask} depth={1} />
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -559,7 +568,7 @@
|
|||
|
||||
<!-- FAB button -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-6 left-0 right-0 z-20 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer || selectedTask ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}"
|
||||
class="pointer-events-none absolute bottom-6 left-0 right-0 z-20 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer || taskStack.length > 0 ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}"
|
||||
>
|
||||
<button
|
||||
onclick={() => { if (app.activeListId) newTaskState.open = true; }}
|
||||
|
|
@ -574,9 +583,20 @@
|
|||
</div>
|
||||
|
||||
<!-- Sub-panel: Task detail -->
|
||||
<div class="relative flex h-full w-1/2 flex-col bg-surface-light dark:bg-surface-dark">
|
||||
{#if selectedTask}
|
||||
<TaskDetailView task={selectedTask} onback={closeDetail} />
|
||||
<div class="relative flex h-full w-1/3 flex-col bg-surface-light dark:bg-surface-dark">
|
||||
{#if parentTask}
|
||||
{#key parentTask.id}
|
||||
<TaskDetailView task={parentTask} onback={closeDetail} onopen={pushTask} />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sub-panel: Subtask detail -->
|
||||
<div class="relative flex h-full w-1/3 flex-col bg-surface-light dark:bg-surface-dark">
|
||||
{#if subtaskDetail}
|
||||
{#key subtaskDetail.id}
|
||||
<TaskDetailView task={subtaskDetail} onback={closeDetail} />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -619,3 +639,25 @@
|
|||
<div class="pointer-events-none absolute inset-0 z-50">
|
||||
<NewTaskInput />
|
||||
</div>
|
||||
|
||||
<!-- Delete list confirmation -->
|
||||
{#if confirmDeleteList}
|
||||
<ConfirmDialog
|
||||
message='Delete list "{app.activeList?.title}" and all its tasks?'
|
||||
confirmText="Delete"
|
||||
danger
|
||||
onconfirm={executeDeleteList}
|
||||
oncancel={() => (confirmDeleteList = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Delete completed tasks confirmation -->
|
||||
{#if confirmDeleteCompleted}
|
||||
<ConfirmDialog
|
||||
message="Delete {app.completedTasks.length} completed task{app.completedTasks.length === 1 ? '' : 's'}?"
|
||||
confirmText="Delete"
|
||||
danger
|
||||
onconfirm={executeDeleteCompleted}
|
||||
oncancel={() => (confirmDeleteCompleted = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ async function createTask(title: string, description?: string, parentId?: string
|
|||
description: description ?? "",
|
||||
parentId: parentId ?? null,
|
||||
});
|
||||
tasks = [...tasks, task];
|
||||
tasks = parentId ? [task, ...tasks] : [...tasks, task];
|
||||
error = null;
|
||||
return task;
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue