Add sliding task detail view with custom date/time picker
Replace inline task editing with a full-viewport detail panel that slides in from the right. Includes editable title, description, custom calendar-based date/time picker (bottom sheet), kebab menu with delete, and mark complete/restore button. Simplify TaskItem to remove inline editing and kebab menu, add chevron hint and onopen callback. Use list icon for drawer toggle instead of back arrow.
This commit is contained in:
parent
0c8c2a0272
commit
c6895c12ae
186
apps/tauri/src/lib/components/DateTimePicker.svelte
Normal file
186
apps/tauri/src/lib/components/DateTimePicker.svelte
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { value = null, onchange, onclose }: {
|
||||||
|
value: string | null;
|
||||||
|
onchange: (iso: string | null) => void;
|
||||||
|
onclose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const DAY_NAMES = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||||
|
|
||||||
|
let now = new Date();
|
||||||
|
let existing = value ? new Date(value) : null;
|
||||||
|
let viewYear = $state(existing ? existing.getFullYear() : now.getFullYear());
|
||||||
|
let viewMonth = $state(existing ? existing.getMonth() : now.getMonth());
|
||||||
|
let selectedDay = $state(existing ? existing.getDate() : now.getDate());
|
||||||
|
let includeTime = $state(existing ? (existing.getHours() !== 0 || existing.getMinutes() !== 0) : false);
|
||||||
|
let selectedHour = $state(existing ? existing.getHours() : now.getHours());
|
||||||
|
let selectedMinute = $state(existing ? existing.getMinutes() : 0);
|
||||||
|
let visible = $state(false);
|
||||||
|
|
||||||
|
let todayStr = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
|
||||||
|
|
||||||
|
let daysInMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
|
||||||
|
let firstDayOfWeek = $derived(new Date(viewYear, viewMonth, 1).getDay());
|
||||||
|
let monthLabel = $derived(new Date(viewYear, viewMonth).toLocaleDateString(undefined, { month: "long", year: "numeric" }));
|
||||||
|
|
||||||
|
let calendarCells = $derived.by(() => {
|
||||||
|
const cells: (number | null)[] = [];
|
||||||
|
for (let i = 0; i < firstDayOfWeek; i++) cells.push(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||||
|
return cells;
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => { visible = true; });
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
visible = false;
|
||||||
|
setTimeout(onclose, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (viewMonth === 0) { viewMonth = 11; viewYear--; }
|
||||||
|
else viewMonth--;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
if (viewMonth === 11) { viewMonth = 0; viewYear++; }
|
||||||
|
else viewMonth++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDay(day: number) {
|
||||||
|
selectedDay = day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(day: number): boolean {
|
||||||
|
return `${viewYear}-${viewMonth}-${day}` === todayStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(day: number): boolean {
|
||||||
|
return selectedDay === day && (!value || (() => {
|
||||||
|
const v = new Date(value);
|
||||||
|
return v.getFullYear() === viewYear && v.getMonth() === viewMonth;
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
function done() {
|
||||||
|
const h = includeTime ? selectedHour : 0;
|
||||||
|
const m = includeTime ? selectedMinute : 0;
|
||||||
|
const iso = new Date(viewYear, viewMonth, selectedDay, h, m).toISOString();
|
||||||
|
onchange(iso);
|
||||||
|
dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
onchange(null);
|
||||||
|
dismiss();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Wrapper: backdrop click dismisses, sheet click stops propagation -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 z-40 transition-opacity duration-200 {visible ? 'opacity-100' : 'opacity-0'}"
|
||||||
|
style="background: rgba(0,0,0,0.4)"
|
||||||
|
onclick={dismiss}
|
||||||
|
onkeydown={(e) => { if (e.key === "Escape") dismiss(); }}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 right-0 rounded-t-2xl bg-surface-light shadow-xl transition-transform duration-200 ease-out dark:bg-card-dark {visible ? 'translate-y-0' : 'translate-y-full'}"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-4 pt-3 pb-2">
|
||||||
|
<span class="text-sm font-semibold">Date & Time</span>
|
||||||
|
<button onclick={done} class="text-sm font-medium text-primary hover:opacity-70">Done</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Month navigation -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-2">
|
||||||
|
<span class="text-sm font-medium">{monthLabel}</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button onclick={prevMonth} class="rounded p-1 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="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick={nextMonth} class="rounded p-1 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="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day names -->
|
||||||
|
<div class="grid grid-cols-7 px-4">
|
||||||
|
{#each DAY_NAMES as name}
|
||||||
|
<div class="py-1 text-center text-xs font-medium opacity-40">{name}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
<div class="grid grid-cols-7 px-4 pb-2">
|
||||||
|
{#each calendarCells as day}
|
||||||
|
{#if day === null}
|
||||||
|
<div></div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => selectDay(day)}
|
||||||
|
class="mx-auto flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors
|
||||||
|
{selectedDay === day ? 'bg-primary text-white' : ''}
|
||||||
|
{isToday(day) && selectedDay !== day ? 'font-bold text-primary' : ''}
|
||||||
|
{selectedDay !== day && !isToday(day) ? 'hover:bg-black/5 dark:hover:bg-white/10' : ''}"
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time section -->
|
||||||
|
<div class="flex items-center gap-3 border-t border-border-light px-4 py-3 dark:border-border-dark">
|
||||||
|
{#if includeTime}
|
||||||
|
<span class="text-sm opacity-50">Time</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<select
|
||||||
|
bind:value={selectedHour}
|
||||||
|
class="appearance-none rounded-lg border border-border-light bg-surface-light px-2 py-1 text-sm text-text-light outline-none dark:border-border-dark dark:bg-surface-dark dark:text-text-dark"
|
||||||
|
>
|
||||||
|
{#each Array(24) as _, h}
|
||||||
|
<option value={h}>{String(h).padStart(2, "0")}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span class="text-sm opacity-40">:</span>
|
||||||
|
<select
|
||||||
|
bind:value={selectedMinute}
|
||||||
|
class="appearance-none rounded-lg border border-border-light bg-surface-light px-2 py-1 text-sm text-text-light outline-none dark:border-border-dark dark:bg-surface-dark dark:text-text-dark"
|
||||||
|
>
|
||||||
|
{#each [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] as m}
|
||||||
|
<option value={m}>{String(m).padStart(2, "0")}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick={() => (includeTime = false)} class="ml-auto opacity-40 hover:opacity-80">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (includeTime = true)}
|
||||||
|
class="text-sm opacity-50 hover:opacity-80"
|
||||||
|
>
|
||||||
|
Set time
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear button -->
|
||||||
|
{#if value}
|
||||||
|
<div class="border-t border-border-light px-4 py-3 dark:border-border-dark">
|
||||||
|
<button onclick={clear} class="text-sm text-danger hover:opacity-70">Clear date</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
195
apps/tauri/src/lib/components/TaskDetailView.svelte
Normal file
195
apps/tauri/src/lib/components/TaskDetailView.svelte
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Task } from "../types";
|
||||||
|
import { app } from "../stores/app.svelte";
|
||||||
|
import DateTimePicker from "./DateTimePicker.svelte";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
const currentPlatform = platform();
|
||||||
|
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
|
||||||
|
|
||||||
|
let { task, onback }: { task: Task; onback: () => void } = $props();
|
||||||
|
|
||||||
|
let title = $state(task.title);
|
||||||
|
let description = $state(task.description);
|
||||||
|
let showMenu = $state(false);
|
||||||
|
let menuEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let showDatePicker = $state(false);
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function handleHeaderMouseDown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
|
if (isDesktop) appWindow.startDragging();
|
||||||
|
}
|
||||||
|
|
||||||
|
function debouncedSave(fields: Partial<Task>) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
app.updateTask({ ...task, ...fields, updated_at: new Date().toISOString() });
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleInput() {
|
||||||
|
debouncedSave({ title: title.trim() || task.title });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDescInput() {
|
||||||
|
debouncedSave({ description });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateChange(iso: string | null) {
|
||||||
|
app.updateTask({ ...task, due_date: iso, updated_at: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle() {
|
||||||
|
await app.toggleTask(task.id);
|
||||||
|
onback();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
showMenu = false;
|
||||||
|
await app.deleteTask(task.id);
|
||||||
|
onback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMenuClickOutside(e: MouseEvent) {
|
||||||
|
if (showMenu && menuEl && !menuEl.contains(e.target as Node)) {
|
||||||
|
showMenu = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showMenu) {
|
||||||
|
window.addEventListener("mousedown", handleMenuClickOutside);
|
||||||
|
return () => window.removeEventListener("mousedown", handleMenuClickOutside);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let isCompleted = $derived(task.status === "completed");
|
||||||
|
|
||||||
|
function formatDateChip(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const today = new Date();
|
||||||
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const day = dayNames[d.getDay()];
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const hasTime = d.getHours() !== 0 || d.getMinutes() !== 0;
|
||||||
|
const timePart = hasTime ? `, ${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
|
||||||
|
if (d.toDateString() === today.toDateString()) return `Today${timePart}`;
|
||||||
|
return `${day}, ${pad(d.getDate())}/${pad(d.getMonth() + 1)}${timePart}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<header
|
||||||
|
onmousedown={handleHeaderMouseDown}
|
||||||
|
class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={onback}
|
||||||
|
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 fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Kebab menu -->
|
||||||
|
<div class="relative" bind:this={menuEl}>
|
||||||
|
<button
|
||||||
|
onclick={() => (showMenu = !showMenu)}
|
||||||
|
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 showMenu}
|
||||||
|
<div class="absolute right-0 top-full z-40 mt-1 min-w-[140px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
|
||||||
|
<button
|
||||||
|
onclick={handleDelete}
|
||||||
|
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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="flex-1 overflow-y-auto px-4 pt-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
oninput={handleTitleInput}
|
||||||
|
placeholder="Task title"
|
||||||
|
class="w-full bg-transparent text-xl font-bold outline-none placeholder:opacity-30"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="mt-4 flex items-start gap-3">
|
||||||
|
<svg class="mt-0.5 h-5 w-5 shrink-0 opacity-40" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4 4a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zm0 4a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zm0 4a1 1 0 011-1h7a1 1 0 110 2H5a1 1 0 01-1-1z" />
|
||||||
|
</svg>
|
||||||
|
<textarea
|
||||||
|
bind:value={description}
|
||||||
|
oninput={handleDescInput}
|
||||||
|
placeholder="Add details"
|
||||||
|
rows="3"
|
||||||
|
class="w-full flex-1 resize-none bg-transparent text-sm outline-none placeholder:opacity-40"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date/time -->
|
||||||
|
<div class="mt-4 flex items-center gap-3">
|
||||||
|
<svg class="h-5 w-5 shrink-0 opacity-40" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{#if task.due_date}
|
||||||
|
<div class="flex items-center gap-1.5 rounded-full border border-border-light bg-black/5 px-3 py-1 text-sm dark:border-border-dark dark:bg-white/10">
|
||||||
|
<button onclick={() => (showDatePicker = true)} class="hover:opacity-70">
|
||||||
|
{formatDateChip(task.due_date)}
|
||||||
|
</button>
|
||||||
|
<button onclick={() => handleDateChange(null)} class="opacity-40 hover:opacity-80">
|
||||||
|
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (showDatePicker = true)}
|
||||||
|
class="text-sm opacity-40 hover:opacity-70"
|
||||||
|
>
|
||||||
|
Add date/time
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Bottom action -->
|
||||||
|
<div class="border-t border-border-light px-4 py-3 dark:border-border-dark">
|
||||||
|
<button
|
||||||
|
onclick={handleToggle}
|
||||||
|
class="w-full text-center text-sm font-medium text-primary hover:opacity-70"
|
||||||
|
>
|
||||||
|
{isCompleted ? "Restore task" : "Mark as completed"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date picker overlay -->
|
||||||
|
{#if showDatePicker}
|
||||||
|
<DateTimePicker
|
||||||
|
value={task.due_date}
|
||||||
|
onchange={handleDateChange}
|
||||||
|
onclose={() => (showDatePicker = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
let editingTaskId = $state<string | null>(null);
|
|
||||||
export const animateInIds = new Set<string>();
|
export const animateInIds = new Set<string>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -7,26 +6,18 @@
|
||||||
import type { Task } from "../types";
|
import type { Task } from "../types";
|
||||||
import { app } from "../stores/app.svelte";
|
import { app } from "../stores/app.svelte";
|
||||||
|
|
||||||
let { task }: { task: Task } = $props();
|
let { task, onopen }: { task: Task; onopen?: (task: Task) => void } = $props();
|
||||||
|
|
||||||
let editTitle = $state(task.title);
|
|
||||||
let editDesc = $state(task.description);
|
|
||||||
let editing = $derived(editingTaskId === task.id);
|
|
||||||
let touchStartX = $state(0);
|
let touchStartX = $state(0);
|
||||||
let swipeX = $state(0);
|
let swipeX = $state(0);
|
||||||
let swiping = $state(false);
|
let swiping = $state(false);
|
||||||
let containerEl = $state<HTMLDivElement | null>(null);
|
|
||||||
let titleInputEl = $state<HTMLInputElement | null>(null);
|
|
||||||
let showMenu = $state(false);
|
|
||||||
let menuEl = $state<HTMLDivElement | null>(null);
|
|
||||||
let transitioning = $state(false);
|
let transitioning = $state(false);
|
||||||
let animatingIn = $state(false);
|
let animatingIn = $state(false);
|
||||||
|
|
||||||
let isCompleted = $derived(task.status === "completed");
|
let isCompleted = $derived(task.status === "completed");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Check on status change whether this task should animate in
|
const _ = task.status;
|
||||||
const _ = task.status; // track reactively
|
|
||||||
if (animateInIds.has(task.id)) {
|
if (animateInIds.has(task.id)) {
|
||||||
animateInIds.delete(task.id);
|
animateInIds.delete(task.id);
|
||||||
animatingIn = true;
|
animatingIn = true;
|
||||||
|
|
@ -38,50 +29,14 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleToggle() {
|
async function handleToggle(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
transitioning = true;
|
transitioning = true;
|
||||||
animateInIds.add(task.id);
|
animateInIds.add(task.id);
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
await app.toggleTask(task.id);
|
await app.toggleTask(task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMenuClickOutside(e: MouseEvent) {
|
|
||||||
if (showMenu && menuEl && !menuEl.contains(e.target as Node)) {
|
|
||||||
showMenu = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (showMenu) {
|
|
||||||
window.addEventListener("mousedown", handleMenuClickOutside);
|
|
||||||
return () => window.removeEventListener("mousedown", handleMenuClickOutside);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function startEditing() {
|
|
||||||
if (editing) return;
|
|
||||||
editingTaskId = task.id;
|
|
||||||
editTitle = task.title;
|
|
||||||
editDesc = task.description;
|
|
||||||
setTimeout(() => titleInputEl?.focus(), 220);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (editingTaskId !== task.id) return;
|
|
||||||
editingTaskId = null;
|
|
||||||
const trimmed = editTitle.trim();
|
|
||||||
if (!trimmed) { editTitle = task.title; return; }
|
|
||||||
if (trimmed === task.title && editDesc === task.description) return;
|
|
||||||
await app.updateTask({ ...task, title: trimmed, description: editDesc });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocusOut(e: FocusEvent) {
|
|
||||||
if (containerEl?.contains(e.relatedTarget as Node)) return;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (editingTaskId === task.id) save();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTouchStart(e: TouchEvent) {
|
function handleTouchStart(e: TouchEvent) {
|
||||||
touchStartX = e.touches[0].clientX;
|
touchStartX = e.touches[0].clientX;
|
||||||
swiping = true;
|
swiping = true;
|
||||||
|
|
@ -98,7 +53,9 @@
|
||||||
if (Math.abs(swipeX) > 100) {
|
if (Math.abs(swipeX) > 100) {
|
||||||
swipeX = 0;
|
swipeX = 0;
|
||||||
swiping = false;
|
swiping = false;
|
||||||
handleToggle();
|
transitioning = true;
|
||||||
|
animateInIds.add(task.id);
|
||||||
|
setTimeout(() => app.toggleTask(task.id), 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
swipeX = 0;
|
swipeX = 0;
|
||||||
|
|
@ -120,9 +77,9 @@
|
||||||
class="grid transition-[grid-template-rows,opacity] duration-300 ease-out {animatingIn || transitioning ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100'}"
|
class="grid transition-[grid-template-rows,opacity] duration-300 ease-out {animatingIn || transitioning ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100'}"
|
||||||
>
|
>
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={containerEl}
|
class="relative"
|
||||||
class="relative {showMenu ? 'z-40' : ''}"
|
|
||||||
ontouchstart={handleTouchStart}
|
ontouchstart={handleTouchStart}
|
||||||
ontouchmove={handleTouchMove}
|
ontouchmove={handleTouchMove}
|
||||||
ontouchend={handleTouchEnd}
|
ontouchend={handleTouchEnd}
|
||||||
|
|
@ -139,15 +96,15 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Task content -->
|
<!-- Task content -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<button
|
||||||
<div
|
class="group flex w-full items-start gap-3 bg-surface-light px-4 py-3 text-left hover:bg-black/5 dark:bg-surface-dark dark:hover:bg-white/5"
|
||||||
class="group relative flex items-start gap-3 bg-surface-light px-4 py-3 hover:bg-black/5 dark:bg-surface-dark dark:hover:bg-white/5"
|
|
||||||
style="transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
|
style="transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
|
||||||
onmousedown={startEditing}
|
onclick={() => onopen?.(task)}
|
||||||
>
|
>
|
||||||
<!-- Checkbox -->
|
<!-- Checkbox -->
|
||||||
<button
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
onmousedown={(e) => { e.stopPropagation(); handleToggle(); }}
|
<div
|
||||||
|
onclick={handleToggle}
|
||||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors {isCompleted
|
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors {isCompleted
|
||||||
? 'border-primary bg-primary'
|
? 'border-primary bg-primary'
|
||||||
: 'border-gray-400 dark:border-gray-500'}"
|
: 'border-gray-400 dark:border-gray-500'}"
|
||||||
|
|
@ -160,19 +117,10 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="min-w-0 flex-1" onfocusout={handleFocusOut}>
|
<div class="min-w-0 flex-1">
|
||||||
{#if editing}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:this={titleInputEl}
|
|
||||||
bind:value={editTitle}
|
|
||||||
class="w-full bg-transparent text-sm font-medium outline-none"
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") (e.target as HTMLElement).blur(); if (e.key === "Escape") { editTitle = task.title; editDesc = task.description; editingTaskId = null; } }}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm {isCompleted ? 'line-through opacity-50' : 'font-medium'}">
|
<p class="text-sm {isCompleted ? 'line-through opacity-50' : 'font-medium'}">
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -184,49 +132,13 @@
|
||||||
{formatDate(task.due_date)}
|
{formatDate(task.due_date)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Expandable edit description -->
|
|
||||||
<div class="grid transition-[grid-template-rows,opacity] duration-200 ease-out {editing ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}">
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
<textarea
|
|
||||||
bind:value={editDesc}
|
|
||||||
placeholder="Add description…"
|
|
||||||
rows="2"
|
|
||||||
class="mt-1 w-full resize-none bg-transparent text-xs opacity-60 outline-none"
|
|
||||||
tabindex={editing ? 0 : -1}
|
|
||||||
onkeydown={(e) => { if (e.key === "Escape") { editTitle = task.title; editDesc = task.description; editingTaskId = null; } }}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Kebab menu -->
|
<!-- Chevron -->
|
||||||
<div class="relative shrink-0" bind:this={menuEl}>
|
<svg class="mt-1 h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-30" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<button
|
<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" />
|
||||||
onmousedown={(e) => { e.stopPropagation(); showMenu = !showMenu; }}
|
|
||||||
class="rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80 {showMenu || editing ? '!opacity-40' : ''}"
|
|
||||||
title="More"
|
|
||||||
>
|
|
||||||
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if showMenu}
|
|
||||||
<div class="absolute right-0 top-full z-40 mt-1 min-w-[140px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
|
|
||||||
<button
|
|
||||||
onmousedown={(e) => { e.stopPropagation(); showMenu = false; app.deleteTask(task.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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue