Merge pull request #6 from SteelDynamite/gui-enhancements

gui-enhancements
This commit is contained in:
SteelDynamite 2026-03-30 07:56:52 -07:00 committed by GitHub
commit 8db4258fe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 671 additions and 255 deletions

View file

@ -706,6 +706,8 @@ WorkspaceConfig {
- [x] Settings popup overlay (WebDAV config, dark mode toggle) - [x] Settings popup overlay (WebDAV config, dark mode toggle)
- [x] Dark mode (GNOME-style neutral theme, cyan-blue accent) - [x] Dark mode (GNOME-style neutral theme, cyan-blue accent)
- [x] Animated completed section show/hide - [x] Animated completed section show/hide
- [ ] Move task between lists (needs `move_task(from_list, to_list, task_id)` added to bevy-tasks-core + Tauri command, then wire into task detail kebab menu)
- [ ] Optional time on due dates (backend `due_date` is `DateTime<Utc>` — needs a separate `due_time` field or a nullable time component so date-only tasks don't default to midnight; currently the GUI uses `hours == 0 && minutes == 0` as a heuristic for "no time set" which breaks for actual midnight times)
- [ ] Due date picker/editor (backend supports it, needs date input in new task toast + inline editing) - [ ] Due date picker/editor (backend supports it, needs date input in new task toast + inline editing)
- [ ] WebDAV setup flow with credentials (settings panel has fields, triggerSync needs to pull creds from config) - [ ] WebDAV setup flow with credentials (settings panel has fields, triggerSync needs to pull creds from config)
- [ ] List/workspace rename (needs `rename_list` added to bevy-tasks-core first) - [ ] List/workspace rename (needs `rename_list` added to bevy-tasks-core first)

View file

@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0" "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-os": "^2.3.2"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
@ -1421,6 +1422,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-os": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz",
"integrity": "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View file

@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0" "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-os": "^2.3.2"
} }
} }

View file

@ -13,6 +13,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
bevy-tasks-core = { path = "../../../crates/bevy-tasks-core" } bevy-tasks-core = { path = "../../../crates/bevy-tasks-core" }

View file

@ -0,0 +1,15 @@
{
"identifier": "default",
"description": "Default capabilities for Bevy Tasks",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"os:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging",
"core:window:allow-is-maximized"
]
}

View file

@ -353,6 +353,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.manage(Mutex::new(AppState { config, repo: None })) .manage(Mutex::new(AppState { config, repo: None }))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_config, get_config,

View file

@ -18,7 +18,9 @@
"height": 700, "height": 700,
"minWidth": 320, "minWidth": 320,
"minHeight": 500, "minHeight": 500,
"resizable": true "resizable": true,
"decorations": false,
"transparent": true
} }
], ],
"security": { "security": {

View file

@ -11,22 +11,25 @@
</script> </script>
<div class={app.darkMode ? "dark" : ""}> <div class={app.darkMode ? "dark" : ""}>
<div <div class="h-screen w-screen p-2">
class="h-screen w-screen overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark" <div
> class="relative h-full w-full overflow-hidden rounded-xl border border-black/15 bg-surface-light text-text-light dark:border-white/15 dark:bg-surface-dark dark:text-text-dark"
{#if app.error} style="container-type: inline-size; box-shadow: 0 2px 8px rgba(0,0,0,0.25), 0 0 2px rgba(0,0,0,0.1)"
<div >
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-between bg-danger px-4 py-2 text-sm text-white" {#if app.error}
> <div
<span>{app.error}</span> class="absolute top-0 left-0 right-0 z-50 flex items-center justify-between bg-danger px-4 py-2 text-sm text-white"
<button onclick={() => app.clearError()} class="ml-2 font-bold"></button> >
</div> <span>{app.error}</span>
{/if} <button onclick={() => app.clearError()} class="ml-2 font-bold"></button>
</div>
{/if}
{#if app.screen === "setup"} {#if app.screen === "setup"}
<SetupScreen /> <SetupScreen />
{:else} {:else}
<TasksScreen /> <TasksScreen />
{/if} {/if}
</div>
</div> </div>
</div> </div>

View file

@ -24,11 +24,16 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
html {
background: transparent;
}
body { body {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
background: transparent;
} }
/* Scrollbar styling */ /* Scrollbar styling */
@ -42,3 +47,9 @@ body {
.dark ::-webkit-scrollbar-thumb { .dark ::-webkit-scrollbar-thumb {
background: #4b5563; background: #4b5563;
} }
/* Select dropdown theming */
.dark select option {
background-color: #242424;
color: #e5e7eb;
}

View 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>

View 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}

View file

@ -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,73 +117,28 @@
/> />
</svg> </svg>
{/if} {/if}
</button>
<!-- Content -->
<div class="min-w-0 flex-1" onfocusout={handleFocusOut}>
{#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'}">
{task.title}
</p>
{#if task.description}
<p class="mt-0.5 text-xs opacity-40 line-clamp-1">{task.description}</p>
{/if}
{#if task.due_date}
<span class="mt-1 inline-block rounded-full border border-border-light px-2 py-0.5 text-xs opacity-50 dark:border-border-dark">
{formatDate(task.due_date)}
</span>
{/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 --> <!-- Content -->
<div class="relative shrink-0" bind:this={menuEl}> <div class="min-w-0 flex-1">
<button <p class="text-sm {isCompleted ? 'line-through opacity-50' : 'font-medium'}">
onmousedown={(e) => { e.stopPropagation(); showMenu = !showMenu; }} {task.title}
class="rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80 {showMenu || editing ? '!opacity-40' : ''}" </p>
title="More" {#if task.description}
> <p class="mt-0.5 text-xs opacity-40 line-clamp-1">{task.description}</p>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> {/if}
<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" /> {#if task.due_date}
</svg> <span class="mt-1 inline-block rounded-full border border-border-light px-2 py-0.5 text-xs opacity-50 dark:border-border-dark">
</button> {formatDate(task.due_date)}
{#if showMenu} </span>
<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"> {/if}
<button </div>
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" <!-- Chevron -->
> <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">
<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" />
<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>
</svg> </button>
Delete
</button>
</div>
{/if}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -41,20 +41,19 @@
</script> </script>
<header <header
class="flex items-center gap-3 border-b border-border-light px-4 py-3 dark:border-border-dark" class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark"
> >
<h1 class="text-lg font-bold">Settings</h1>
<button <button
onclick={() => onclose?.()} onclick={() => onclose?.()}
class="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" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path <path
fill-rule="evenodd" 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"
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> </svg>
</button> </button>
<h1 class="text-lg font-bold">Settings</h1>
</header> </header>
<main class="flex-1 overflow-y-auto p-4"> <main class="flex-1 overflow-y-auto p-4">

View file

@ -1,8 +1,28 @@
<script lang="ts"> <script lang="ts">
import { app } from "../stores/app.svelte"; import { app } from "../stores/app.svelte";
import TaskItem from "../components/TaskItem.svelte"; import TaskItem from "../components/TaskItem.svelte";
import TaskDetailView from "../components/TaskDetailView.svelte";
import NewTaskInput, { newTaskState } from "../components/NewTaskInput.svelte"; import NewTaskInput, { newTaskState } from "../components/NewTaskInput.svelte";
import SettingsScreen from "./SettingsScreen.svelte"; import SettingsScreen from "./SettingsScreen.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import type { Task } from "../types";
const appWindow = getCurrentWindow();
const currentPlatform = platform();
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);
function openTask(task: Task) {
selectedTaskId = task.id;
}
function closeDetail() {
selectedTaskId = null;
}
let showDrawer = $state(false); let showDrawer = $state(false);
let showSettings = $state(false); let showSettings = $state(false);
@ -23,8 +43,8 @@
window.addEventListener("mousedown", handleWindowClick); window.addEventListener("mousedown", handleWindowClick);
} }
let newListName = $state(""); let newListName = $state("");
let showCompleted = $state(true); let showCompleted = $state(false);
let completedVisible = $state(true); let completedVisible = $state(false);
let listMenuId = $state<string | null>(null); let listMenuId = $state<string | null>(null);
let wsMenuName = $state<string | null>(null); let wsMenuName = $state<string | null>(null);
let dragId = $state<string | null>(null); let dragId = $state<string | null>(null);
@ -113,19 +133,25 @@
showSettings = false; showSettings = false;
} }
function handleHeaderMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return;
if (isDesktop) appWindow.startDragging();
}
let workspaceNames = $derived(app.config ? Object.keys(app.config.workspaces) : []); let workspaceNames = $derived(app.config ? Object.keys(app.config.workspaces) : []);
let translateX = $derived(showDrawer ? '0' : '-80vw'); let translateX = $derived(showDrawer ? '0' : '-80cqi');
</script> </script>
<!-- Viewport clip --> <!-- Viewport clip -->
<div class="h-screen w-screen overflow-hidden"> <div class="h-full w-full overflow-hidden">
<!-- Sliding container: left drawer + main content --> <!-- Sliding container: left drawer + main content -->
<div <div
class="flex h-full ease-out {resizing ? '' : 'transition-transform duration-250'}" class="flex h-full ease-out {resizing ? '' : 'transition-transform duration-250'}"
style="width: calc(100vw + 80vw); transform: translateX({translateX})" style="width: calc(100cqi + 80cqi); transform: translateX({translateX})"
> >
<!-- Drawer panel --> <!-- Drawer panel -->
<div class="flex h-full w-[80vw] shrink-0 flex-col bg-surface-light dark:bg-surface-dark"> <div class="flex h-full shrink-0 flex-col bg-surface-light dark:bg-surface-dark" style="width: 80cqi">
<!-- List items + new list button --> <!-- List items + new list button -->
<div class="flex-1 overflow-y-auto py-2"> <div class="flex-1 overflow-y-auto py-2">
{#each app.lists as list (list.id)} {#each app.lists as list (list.id)}
@ -287,7 +313,7 @@
</div> </div>
<!-- Main content panel --> <!-- Main content panel -->
<div class="relative flex h-full w-screen shrink-0 flex-col bg-surface-light dark:bg-surface-dark"> <div class="relative h-full shrink-0 overflow-hidden bg-surface-light dark:bg-surface-dark" style="width: 100cqi">
<!-- Dim overlay when drawer is open --> <!-- Dim overlay when drawer is open -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
@ -296,115 +322,167 @@
onclick={closeDrawer} onclick={closeDrawer}
onkeydown={(e) => { if (e.key === "Escape") closeDrawer(); }} onkeydown={(e) => { if (e.key === "Escape") closeDrawer(); }}
></div> ></div>
<!-- Header -->
<header <!-- Sliding inner: task list + detail view -->
class="relative flex items-center border-b border-border-light px-4 py-3 dark:border-border-dark" <div
class="flex h-full {resizing ? '' : 'transition-transform duration-250'} ease-out"
style="width: 200%; transform: translateX({selectedTask ? '-50%' : '0'})"
> >
<!-- Back arrow (left) --> <!-- Sub-panel: Task list -->
<button <div class="relative flex h-full w-1/2 flex-col">
onclick={() => (showDrawer = !showDrawer)} <!-- Header / drag region -->
class="absolute left-2 rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10" <!-- svelte-ignore a11y_no_static_element_interactions -->
> <header
<svg class="h-5 w-5 opacity-60" viewBox="0 0 20 20" fill="currentColor"> onmousedown={handleHeaderMouseDown}
<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" /> ondblclick={() => { if (isDesktop) appWindow.toggleMaximize(); }}
</svg> class="relative flex items-center border-b border-border-light px-4 py-3 dark:border-border-dark"
</button> >
<!-- Drawer toggle (left) -->
<!-- Centered title -->
<div class="flex-1 text-center">
<p class="text-xs text-text-secondary-light dark:text-text-secondary-dark">
{app.config?.current_workspace ?? ""}
</p>
<p class="text-lg font-bold">{app.activeList?.title ?? "Tasks"}</p>
</div>
<!-- Sync spinner (right) -->
{#if app.syncing}
<div class="absolute right-4 h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
{/if}
</header>
<!-- Task list -->
<main class="flex-1 overflow-y-auto">
{#if app.lists.length === 0}
<div class="flex h-full flex-col items-center justify-center p-8 text-center">
<p class="text-lg font-medium opacity-60">No lists yet</p>
<p class="mt-1 text-sm opacity-40">Tap the list name above to create one</p>
</div>
{:else if !app.activeListId}
<div class="flex h-full items-center justify-center opacity-40">
Select a list
</div>
{:else}
{#each app.pendingTasks as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
draggable="true"
ondragstart={(e) => handleDragStart(e, task.id)}
ondragover={(e) => handleDragOver(e, task.id)}
ondragend={handleDragEnd}
ondrop={(e) => handleDrop(e, task.id)}
class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}"
>
<TaskItem {task} />
</div>
{/each}
{#if app.pendingTasks.length === 0}
<div class="p-8 text-center text-sm opacity-40">No tasks. Add one below.</div>
{/if}
{#if app.completedTasks.length > 0}
<div class="h-4"></div>
<button <button
onclick={() => { onclick={() => (showDrawer = !showDrawer)}
if (showCompleted) { class="absolute left-2 rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
showCompleted = false;
setTimeout(() => (completedVisible = false), 300);
} else {
completedVisible = true;
requestAnimationFrame(() => (showCompleted = true));
}
}}
class="relative z-10 flex w-full items-center justify-between border-t border-border-light bg-surface-light px-4 py-3 text-sm font-medium text-text-secondary-light transition-colors hover:bg-black/5 dark:border-border-dark dark:bg-surface-dark dark:text-text-secondary-dark dark:hover:bg-white/5"
> >
Completed ({app.completedTasks.length}) <svg class="h-5 w-5 opacity-60" viewBox="0 0 20 20" fill="currentColor">
<svg <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" />
class="h-4 w-4 transition-transform {showCompleted ? '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> </svg>
</button> </button>
{#if completedVisible}
<div class="transition-all duration-300 ease-out {showCompleted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}"> <!-- Centered title -->
{#each app.completedTasks as task (task.id)} <div class="flex-1 text-center">
<TaskItem {task} /> <p class="text-xs text-text-secondary-light dark:text-text-secondary-dark">
{/each} {app.config?.current_workspace ?? ""}
</p>
<p class="text-lg font-bold">{app.activeList?.title ?? "Tasks"}</p>
</div>
<!-- Window controls (right) -->
{#if isDesktop}
<div class="absolute right-1.5 flex items-center gap-0.5">
{#if isWindows}
<button
onclick={() => appWindow.minimize()}
class="rounded p-1.5 opacity-50 hover:bg-black/10 hover:opacity-80 dark:hover:bg-white/10"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M4 10a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" />
</svg>
</button>
<button
onclick={() => appWindow.toggleMaximize()}
class="rounded p-1.5 opacity-50 hover:bg-black/10 hover:opacity-80 dark:hover:bg-white/10"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="14" height="14" rx="1" />
</svg>
</button>
{/if}
<button
onclick={() => appWindow.close()}
class="rounded p-1.5 opacity-50 hover:bg-danger/20 hover:opacity-100 hover:text-danger dark:hover:bg-danger/20"
>
<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> </div>
{/if} {/if}
{/if} </header>
{/if}
</main>
<!-- FAB button --> <!-- Task list -->
<div <main class="flex-1 overflow-y-auto">
class="pointer-events-none absolute bottom-6 left-0 right-0 z-30 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}" {#if app.lists.length === 0}
> <div class="flex h-full flex-col items-center justify-center p-8 text-center">
<button <p class="text-lg font-medium opacity-60">No lists yet</p>
onclick={() => { if (app.activeListId) newTaskState.open = true; }} <p class="mt-1 text-sm opacity-40">Tap the list name above to create one</p>
disabled={!app.activeListId} </div>
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary text-white shadow-lg transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:shadow-none" {:else if !app.activeListId}
> <div class="flex h-full items-center justify-center opacity-40">
<svg class="h-7 w-7" viewBox="0 0 20 20" fill="currentColor"> Select a list
<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" /> </div>
</svg> {:else}
</button> {#each app.pendingTasks as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
draggable="true"
ondragstart={(e) => handleDragStart(e, task.id)}
ondragover={(e) => handleDragOver(e, task.id)}
ondragend={handleDragEnd}
ondrop={(e) => handleDrop(e, task.id)}
class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}"
>
<TaskItem {task} onopen={openTask} />
</div>
{/each}
{#if app.pendingTasks.length === 0}
<div class="p-8 text-center text-sm opacity-40">No tasks. Add one below.</div>
{/if}
{#if app.completedTasks.length > 0}
<div class="h-4"></div>
<button
onclick={() => {
if (showCompleted) {
showCompleted = false;
setTimeout(() => (completedVisible = false), 300);
} else {
completedVisible = true;
requestAnimationFrame(() => (showCompleted = true));
}
}}
class="relative z-10 flex w-full items-center justify-between border-t border-border-light bg-surface-light px-4 py-3 text-sm font-medium text-text-secondary-light transition-colors hover:bg-black/5 dark:border-border-dark dark:bg-surface-dark dark:text-text-secondary-dark dark:hover:bg-white/5"
>
Completed ({app.completedTasks.length})
<svg
class="h-4 w-4 transition-transform {showCompleted ? '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>
{#if completedVisible}
<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}
</div>
{/if}
{/if}
{/if}
</main>
<!-- 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'}"
>
<button
onclick={() => { if (app.activeListId) newTaskState.open = true; }}
disabled={!app.activeListId}
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary text-white shadow-lg transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:shadow-none"
>
<svg class="h-7 w-7" 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>
</button>
</div>
</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} />
{/if}
</div>
</div> </div>
<!-- Sync spinner -->
{#if app.syncing}
<div class="absolute bottom-4 right-4 z-20 h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -412,7 +490,7 @@
<!-- Settings popup overlay --> <!-- Settings popup overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 z-50 flex transition-opacity duration-200 {showSettings ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}" class="absolute inset-0 z-50 flex transition-opacity duration-200 {showSettings ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}"
style="padding: 4%" style="padding: 4%"
> >
<!-- Backdrop --> <!-- Backdrop -->
@ -431,6 +509,6 @@
</div> </div>
<!-- Toast overlay (outside sliding container so it stays centered) --> <!-- Toast overlay (outside sliding container so it stays centered) -->
<div class="pointer-events-none fixed inset-0 z-50"> <div class="pointer-events-none absolute inset-0 z-50">
<NewTaskInput /> <NewTaskInput />
</div> </div>