feat(tauri): move-to-list, rename, group toggle, keyboard shortcuts, WebDAV fix
- TaskDetailView: 'Move to...' submenu in kebab menu - TasksScreen: list rename (inline input), group-by-due-date toggle, global Escape key handler for closing overlays - SettingsScreen: auto-populate WebDAV URL/credentials on open - SetupScreen: add window dragging, minimize/close buttons, 'Open Existing Folder' button - Store: moveTask, renameList, setGroupByDueDate methods + fs-changed event listener for file watcher Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5faf285d28
commit
1a967c7fdd
|
|
@ -14,10 +14,13 @@
|
|||
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 otherLists = $derived(app.lists.filter((l) => l.id !== app.activeListId));
|
||||
|
||||
function handleHeaderMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
|
|
@ -126,6 +129,34 @@
|
|||
</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}
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,22 @@
|
|||
let webdavPass = $state("");
|
||||
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
|
||||
|
||||
$effect(() => {
|
||||
const ws = app.config?.current_workspace;
|
||||
if (!ws) return;
|
||||
const cfg = app.config?.workspaces[ws];
|
||||
if (cfg?.webdav_url) {
|
||||
webdavUrl = cfg.webdav_url;
|
||||
try {
|
||||
const domain = new URL(cfg.webdav_url).hostname;
|
||||
invoke<[string, string]>("load_credentials", { domain }).then(([u, p]) => {
|
||||
webdavUser = u;
|
||||
webdavPass = p;
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
async function testConnection() {
|
||||
testStatus = "testing";
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { app } from "../stores/app.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";
|
||||
const isWindows = currentPlatform === "windows";
|
||||
|
||||
let name = $state("");
|
||||
let path = $state("");
|
||||
|
|
@ -14,51 +21,109 @@
|
|||
if (!name.trim() || !path.trim()) return;
|
||||
await app.addWorkspace(name.trim(), path.trim());
|
||||
}
|
||||
|
||||
async function handleOpen() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (!selected) return;
|
||||
const folder = selected as string;
|
||||
// Derive workspace name from folder name
|
||||
const parts = folder.replace(/\\/g, "/").split("/");
|
||||
const wsName = parts[parts.length - 1] || "workspace";
|
||||
await app.addWorkspace(wsName, folder);
|
||||
}
|
||||
|
||||
function handleDrag(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||
if (isDesktop) appWindow.startDragging();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full items-center justify-center p-6">
|
||||
<div
|
||||
class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark"
|
||||
>
|
||||
<h1 class="mb-1 text-2xl font-bold">Onyx</h1>
|
||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||
Create or open a workspace to get started.
|
||||
</p>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="flex h-full flex-col" onmousedown={handleDrag}>
|
||||
<!-- Title bar area with window controls -->
|
||||
<header class="flex h-11 shrink-0 items-center justify-end px-2">
|
||||
{#if isDesktop}
|
||||
<div class="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>
|
||||
{/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>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<label class="mb-1 block text-sm font-medium">
|
||||
Workspace name
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="My Tasks"
|
||||
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-1 items-center justify-center p-6">
|
||||
<div
|
||||
class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark"
|
||||
>
|
||||
<h1 class="mb-1 text-2xl font-bold">Onyx</h1>
|
||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||
Create a new workspace or open an existing one.
|
||||
</p>
|
||||
|
||||
<label class="mb-1 block text-sm font-medium">
|
||||
Workspace name
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="My Tasks"
|
||||
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label class="mb-1 block text-sm font-medium">Folder</label>
|
||||
<div class="mb-6 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={path}
|
||||
readonly
|
||||
placeholder="Select a folder…"
|
||||
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm dark:border-border-dark"
|
||||
/>
|
||||
<button
|
||||
onclick={pickFolder}
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label class="mb-1 block text-sm font-medium">Folder</label>
|
||||
<div class="mb-6 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={path}
|
||||
readonly
|
||||
placeholder="Select a folder…"
|
||||
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm dark:border-border-dark"
|
||||
/>
|
||||
<button
|
||||
onclick={pickFolder}
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
|
||||
onclick={handleCreate}
|
||||
disabled={!name.trim() || !path.trim()}
|
||||
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||
>
|
||||
Browse
|
||||
Create Workspace
|
||||
</button>
|
||||
|
||||
<div class="my-4 flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
|
||||
<span class="text-xs opacity-40">or</span>
|
||||
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={handleOpen}
|
||||
class="w-full rounded-lg border border-border-light py-2.5 text-sm font-medium hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
|
||||
>
|
||||
Open Existing Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={handleCreate}
|
||||
disabled={!name.trim() || !path.trim()}
|
||||
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||
>
|
||||
Create Workspace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@
|
|||
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 dragId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
let resizing = $state(false);
|
||||
|
|
@ -77,6 +79,40 @@
|
|||
await app.deleteList(id);
|
||||
}
|
||||
|
||||
function startRenameList(id: string) {
|
||||
listMenuId = null;
|
||||
const list = app.lists.find(l => l.id === id);
|
||||
if (!list) return;
|
||||
renamingListId = id;
|
||||
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) {
|
||||
await app.renameList(renamingListId, renameValue.trim());
|
||||
}
|
||||
renamingListId = null;
|
||||
}
|
||||
|
||||
async function handleToggleGroupByDueDate(id: string) {
|
||||
listMenuId = null;
|
||||
const list = app.lists.find(l => l.id === id);
|
||||
if (!list) return;
|
||||
await app.setGroupByDueDate(id, !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 (showDrawer) { closeDrawer(); return; }
|
||||
if (listMenuId) { listMenuId = null; return; }
|
||||
if (wsMenuName) { wsMenuName = null; return; }
|
||||
if (showWorkspacePicker) { showWorkspacePicker = false; return; }
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, taskId: string) {
|
||||
dragId = taskId;
|
||||
if (e.dataTransfer) {
|
||||
|
|
@ -148,6 +184,8 @@
|
|||
let translateX = $derived(showDrawer ? '0' : '-80cqi');
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Viewport clip -->
|
||||
<div class="h-full w-full overflow-hidden">
|
||||
<!-- Sliding container: left drawer + main content -->
|
||||
|
|
@ -238,17 +276,30 @@
|
|||
<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">
|
||||
<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>
|
||||
{#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>
|
||||
{/if}
|
||||
<div class="relative shrink-0" data-list-menu>
|
||||
<button
|
||||
onclick={() => (listMenuId = listMenuId === list.id ? null : list.id)}
|
||||
|
|
@ -259,7 +310,30 @@
|
|||
</svg>
|
||||
</button>
|
||||
{#if listMenuId === list.id}
|
||||
<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">
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
AppConfig,
|
||||
Task,
|
||||
|
|
@ -7,6 +8,11 @@ import type {
|
|||
SyncResult,
|
||||
} from "../types";
|
||||
|
||||
// Listen for file system changes from the backend watcher
|
||||
listen("fs-changed", () => {
|
||||
loadLists();
|
||||
});
|
||||
|
||||
// ── Reactive state ───────────────────────────────────────────────────
|
||||
|
||||
let screen = $state<Screen>("setup");
|
||||
|
|
@ -54,6 +60,7 @@ async function addWorkspace(name: string, path: string) {
|
|||
await invoke("add_workspace", { name, path });
|
||||
config = await invoke<AppConfig>("get_config");
|
||||
await loadLists();
|
||||
invoke("watch_workspace", { path }).catch(() => {});
|
||||
screen = "tasks";
|
||||
error = null;
|
||||
} catch (e) {
|
||||
|
|
@ -67,6 +74,8 @@ async function switchWorkspace(name: string) {
|
|||
config = await invoke<AppConfig>("get_config");
|
||||
activeListId = null;
|
||||
await loadLists();
|
||||
const ws = config?.workspaces[name];
|
||||
if (ws) invoke("watch_workspace", { path: ws.path }).catch(() => {});
|
||||
error = null;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
|
|
@ -206,6 +215,43 @@ async function deleteTask(taskId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function moveTask(taskId: string, targetListId: string) {
|
||||
if (!activeListId) return;
|
||||
try {
|
||||
await invoke("move_task", {
|
||||
fromListId: activeListId,
|
||||
toListId: targetListId,
|
||||
taskId,
|
||||
});
|
||||
tasks = tasks.filter((t) => t.id !== taskId);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameList(listId: string, newName: string) {
|
||||
try {
|
||||
await invoke("rename_list", { listId, newName });
|
||||
lists = lists.map((l) =>
|
||||
l.id === listId ? { ...l, title: newName } : l,
|
||||
);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function setGroupByDueDate(listId: string, enabled: boolean) {
|
||||
try {
|
||||
await invoke("set_group_by_due_date", { listId, enabled });
|
||||
lists = lists.map((l) =>
|
||||
l.id === listId ? { ...l, group_by_due_date: enabled } : l,
|
||||
);
|
||||
if (listId === activeListId) await loadTasks();
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
if (!config?.current_workspace) return;
|
||||
const ws = config.workspaces[config.current_workspace];
|
||||
|
|
@ -300,6 +346,9 @@ export const app = {
|
|||
updateTask,
|
||||
reorderTask,
|
||||
deleteTask,
|
||||
moveTask,
|
||||
renameList,
|
||||
setGroupByDueDate,
|
||||
triggerSync,
|
||||
toggleDarkMode,
|
||||
setScreen,
|
||||
|
|
|
|||
Loading…
Reference in a new issue