Quick add task from the header (owner): modal, g t shortcut, auto_handle + Daisy webhook
Some checks failed
Deploy production / deploy (push) Has been cancelled

- + button next to Dashboard (desktop and mobile sheet), lazy-loaded native <dialog>
- POST /trackers/tasks/quick inserts source 'quick-add', notes, due date, priority
- tasks.tasks.auto_handle (default false) with a Daisy badge in the tracker
- DAISY_TASK_WEBHOOK_URL/KEY/HEADER: fire-and-forget POST {task_id, title}, 3 s timeout
This commit is contained in:
2026-10-24 16:13:44 +00:00
parent 3903317a59
commit 0aa14cebf3
9 changed files with 20152 additions and 2 deletions

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
// Quick add task modal, lazy-loaded by SiteChrome on first open (button or g then t).
// A native <dialog>: focus trap, Esc and backdrop come with the browser, no UI library.
let { onclose, ondone }: { onclose: () => void; ondone: (message: string) => void } = $props();
let dialog = $state<HTMLDialogElement>();
let title = $state('');
let notes = $state('');
let due = $state('');
let priority = $state(0);
let autoHandle = $state(false);
let saving = $state(false);
let problem = $state('');
onMount(() => {
dialog?.showModal();
});
async function submit(event: SubmitEvent) {
event.preventDefault();
if (!title.trim() || saving) return;
saving = true;
problem = '';
try {
const res = await fetch('/trackers/tasks/quick', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title, notes, due, priority, autoHandle })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
problem = body.message ?? `Could not save (HTTP ${res.status})`;
return;
}
ondone(
body.hook === 'sent'
? `Added “${body.title}”, Daisy is on it`
: body.hook === 'not-configured'
? `Added “${body.title}” (Daisy hook not configured)`
: `Added “${body.title}”`
);
dialog?.close();
invalidateAll();
} catch {
problem = 'Could not reach the server';
} finally {
saving = false;
}
}
// Enter in the title submits; in the description it makes a new line.
function titleKey(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
(event.currentTarget as HTMLInputElement).form?.requestSubmit();
}
}
</script>
<dialog
bind:this={dialog}
{onclose}
class="m-auto w-[min(28rem,calc(100vw-2rem))] rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl backdrop:bg-black/50"
aria-labelledby="quick-add-title"
>
<form class="flex flex-col gap-3 p-4" onsubmit={submit}>
<h2 id="quick-add-title" class="text-base font-semibold">Quick add task</h2>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={title}
onkeydown={titleKey}
required
maxlength="500"
autofocus
placeholder="What needs doing? (! flags, !!! = high)"
aria-label="Title"
class="rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<textarea
bind:value={notes}
rows="3"
placeholder="Description (optional)"
aria-label="Description"
class="resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
></textarea>
<div class="grid grid-cols-2 gap-3 text-sm">
<label class="flex flex-col gap-1"
><span class="text-muted-foreground">Deadline</span><input
type="date"
bind:value={due}
class="rounded-md border border-input bg-background px-2 py-1.5"
/></label
>
<label class="flex flex-col gap-1"
><span class="text-muted-foreground">Priority</span><select
bind:value={priority}
class="rounded-md border border-input bg-background px-2 py-1.5"
>
<option value={0}>None</option>
<option value={1}>Low</option>
<option value={2}>Medium</option>
<option value={3}>High</option>
</select></label
>
</div>
<label class="flex items-center gap-2 text-sm"
><input type="checkbox" bind:checked={autoHandle} class="size-4" /> Let Daisy handle it</label
>
{#if problem}<p class="text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
onclick={() => dialog?.close()}>Cancel</button
>
<button
type="submit"
disabled={saving || !title.trim()}
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
>{saving ? 'Adding…' : 'Add task'}</button
>
</div>
</form>
</dialog>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { onMount, type Component, type Snippet } from 'svelte';
import { resolve } from '$app/paths';
import logo from '#lib/assets/brand/favicon-32x32.png?url';
import { hidesFooter, isTyping } from '#lib/chrome.js';
@@ -15,6 +15,7 @@
import ExternalLink from 'lucide-svelte/icons/external-link';
import Menu from 'lucide-svelte/icons/menu';
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
import Plus from 'lucide-svelte/icons/plus';
import SiOsu from '~icons/simple-icons/osu';
import SiSteam from '~icons/simple-icons/steam';
import SiTwitch from '~icons/simple-icons/twitch';
@@ -117,7 +118,8 @@
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
}
// Owner shortcut: press g then d (outside text fields) to open the /start dashboard.
// Owner shortcuts (outside text fields): g then d opens the /start dashboard, g then t
// opens quick add task.
let gPressedAt = 0;
function handleShortcut(event: KeyboardEvent) {
if (!data.isOwner || event.defaultPrevented || event.repeat) return;
@@ -125,6 +127,10 @@
const key = event.key.toLowerCase();
if (key === 'g') {
gPressedAt = event.timeStamp;
} else if (key === 't' && gPressedAt && event.timeStamp - gPressedAt < 1500) {
gPressedAt = 0;
event.preventDefault();
openQuickAdd();
} else if (key === 'd' && gPressedAt && event.timeStamp - gPressedAt < 1500) {
gPressedAt = 0;
event.preventDefault();
@@ -135,6 +141,25 @@
}
}
// Quick add task: the modal's code is fetched on first open only.
let QuickAdd = $state<Component<{ onclose: () => void; ondone: (m: string) => void }> | null>(
null
);
let quickAddOpen = $state(false);
let toast = $state('');
let toastTimer: ReturnType<typeof setTimeout> | undefined;
async function openQuickAdd() {
if (!data.isOwner) return;
menuOpen = false;
QuickAdd ??= (await import('#lib/components/QuickAddTask.svelte')).default;
quickAddOpen = true;
}
function showToast(message: string) {
toast = message;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ''), 4000);
}
function handleMenuPointerEnter(event: PointerEvent, href: string) {
if (event.pointerType === 'touch') return;
openMenu = href;
@@ -285,6 +310,14 @@
aria-keyshortcuts="g d"
><LayoutDashboard size={16} aria-hidden="true" />dashboard</Button
>
<Button
variant="ghost"
size="icon"
onclick={openQuickAdd}
aria-label="Quick add task"
title="Quick add task (g then t)"
aria-keyshortcuts="g t"><Plus size={18} aria-hidden="true" /></Button
>
{/if}
<Button
variant="ghost"
@@ -326,6 +359,9 @@
onclick={() => (menuOpen = false)}
><LayoutDashboard size={16} aria-hidden="true" />dashboard</Button
>
<Button variant="outline" class="mb-2 justify-start gap-2" onclick={openQuickAdd}
><Plus size={16} aria-hidden="true" />quick add task</Button
>
{/if}
{#each navigation as item (item.href)}
<Button
@@ -436,6 +472,18 @@
{/if}
</div>
{#if QuickAdd && quickAddOpen}
<QuickAdd onclose={() => (quickAddOpen = false)} ondone={showToast} />
{/if}
{#if toast}
<div
role="status"
class="fixed bottom-4 left-1/2 z-[60] -translate-x-1/2 rounded-md border border-border bg-popover px-4 py-2 text-sm text-popover-foreground shadow-lg"
>
{toast}
</div>
{/if}
<style>
@media (min-width: 1100px) and (min-height: 700px) {
.cod-footer {