feat(web): open a task
TaskRow has had an onedit prop that nothing passed. There was no way to see a task's description, sub-tasks, comments, files or reminders, all of which the API has supported from the start. A drawer rather than a page, so the list you came from stays behind it and ticking a sub-task off and closing lands you where you were rather than at the top of a re-fetched list. Mounted in the root layout and driven by an id in the store, so a task opens the same way from Today, a project, a label or a saved filter — and so an edit arriving over the socket reaches the drawer without a second source of truth to keep in step. Text fields save on blur. A PATCH per keystroke is forty requests for one sentence, and the last to arrive is not necessarily the last one sent. The navigation shortcuts are suppressed while it is open: with a task in front of you, "t" means the letter t. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
9d9444d0ac
commit
d288606827
@@ -0,0 +1,765 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* One task, opened.
|
||||||
|
*
|
||||||
|
* A drawer rather than a page: the list you came from stays visible behind it,
|
||||||
|
* so ticking a sub-task off and closing lands you back where you were rather
|
||||||
|
* than at the top of a re-fetched list.
|
||||||
|
*
|
||||||
|
* Text fields save on blur, not on every keystroke. A PATCH per character would
|
||||||
|
* be forty requests for one sentence, and the last one to arrive is not
|
||||||
|
* necessarily the last one sent.
|
||||||
|
*/
|
||||||
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
|
||||||
|
let { task, onclose } = $props();
|
||||||
|
|
||||||
|
let content = $state('');
|
||||||
|
let description = $state('');
|
||||||
|
let priority = $state(4);
|
||||||
|
let dueDate = $state('');
|
||||||
|
let labels = $state('');
|
||||||
|
|
||||||
|
let subtasks = $state([]);
|
||||||
|
let comments = $state([]);
|
||||||
|
let attachments = $state([]);
|
||||||
|
let reminders = $state([]);
|
||||||
|
|
||||||
|
let newSubtask = $state('');
|
||||||
|
let newComment = $state('');
|
||||||
|
let newReminder = $state('');
|
||||||
|
let posting = $state(false);
|
||||||
|
let uploading = $state(false);
|
||||||
|
|
||||||
|
// Re-seeded whenever a different task is opened. Keyed on the id rather than on
|
||||||
|
// the object, so a websocket update to the same task does not throw away what
|
||||||
|
// somebody is typing.
|
||||||
|
let seededId = $state(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!task || seededId === task.id) return;
|
||||||
|
seededId = task.id;
|
||||||
|
|
||||||
|
content = task.content;
|
||||||
|
description = task.description ?? '';
|
||||||
|
priority = task.priority;
|
||||||
|
dueDate = task.due_date ?? '';
|
||||||
|
labels = (task.labels ?? []).join(', ');
|
||||||
|
|
||||||
|
subtasks = [];
|
||||||
|
comments = [];
|
||||||
|
attachments = [];
|
||||||
|
reminders = [];
|
||||||
|
|
||||||
|
const id = task.id;
|
||||||
|
api.listTasks({ parent_id: id, completed: 'include' })
|
||||||
|
.then((r) => id === seededId && (subtasks = r.tasks))
|
||||||
|
.catch(() => {});
|
||||||
|
api.listComments(id)
|
||||||
|
.then((r) => {
|
||||||
|
if (id !== seededId) return;
|
||||||
|
comments = r.comments;
|
||||||
|
attachments = r.attachments ?? [];
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
api.listReminders(id)
|
||||||
|
.then((r) => id === seededId && (reminders = r.reminders))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sends a patch through the store, so the row behind the drawer updates too. */
|
||||||
|
async function save(patch) {
|
||||||
|
await app.update(task.id, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveContent() {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed || trimmed === task.content) {
|
||||||
|
content = task.content;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save({ content: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDescription() {
|
||||||
|
if (description === (task.description ?? '')) return;
|
||||||
|
save({ description });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLabels() {
|
||||||
|
const list = labels
|
||||||
|
.split(',')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (list.join() === (task.labels ?? []).join()) return;
|
||||||
|
save({ labels: list });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sub-tasks ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async function addSubtask(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = newSubtask.trim();
|
||||||
|
if (!text) return;
|
||||||
|
newSubtask = '';
|
||||||
|
try {
|
||||||
|
const created = await api.createTask({
|
||||||
|
content: text,
|
||||||
|
project_id: task.project_id,
|
||||||
|
parent_id: task.id
|
||||||
|
});
|
||||||
|
subtasks = [...subtasks, created];
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSubtask(subtask) {
|
||||||
|
const previous = subtasks;
|
||||||
|
subtasks = subtasks.map((s) =>
|
||||||
|
s.id === subtask.id ? { ...s, completed: !s.completed } : s
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const updated = subtask.completed
|
||||||
|
? await api.reopenTask(subtask.id)
|
||||||
|
: await api.completeTask(subtask.id);
|
||||||
|
subtasks = subtasks.map((s) => (s.id === updated.id ? updated : s));
|
||||||
|
} catch (e) {
|
||||||
|
subtasks = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSubtask(subtask) {
|
||||||
|
const previous = subtasks;
|
||||||
|
subtasks = subtasks.filter((s) => s.id !== subtask.id);
|
||||||
|
try {
|
||||||
|
await api.deleteTask(subtask.id);
|
||||||
|
} catch (e) {
|
||||||
|
subtasks = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- comments and files ---------------------------------------------------------
|
||||||
|
|
||||||
|
async function addComment(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const body = newComment.trim();
|
||||||
|
if (!body) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const created = await api.createComment(task.id, body);
|
||||||
|
comments = [...comments, created];
|
||||||
|
newComment = '';
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(e.fields?.body ?? humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeComment(comment) {
|
||||||
|
if (!confirm('Slet kommentaren?')) return;
|
||||||
|
const previous = comments;
|
||||||
|
comments = comments.filter((c) => c.id !== comment.id);
|
||||||
|
try {
|
||||||
|
await api.deleteComment(comment.id);
|
||||||
|
} catch (e) {
|
||||||
|
comments = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(event) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
attachments = [...attachments, await api.uploadAttachment(task.id, file)];
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(e.fields?.file ?? humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAttachment(attachment) {
|
||||||
|
const previous = attachments;
|
||||||
|
attachments = attachments.filter((a) => a.id !== attachment.id);
|
||||||
|
try {
|
||||||
|
await api.deleteAttachment(attachment.id);
|
||||||
|
} catch (e) {
|
||||||
|
attachments = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reminders --------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function addReminder(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!newReminder) return;
|
||||||
|
try {
|
||||||
|
// The input gives local wall-clock time; the API wants an instant.
|
||||||
|
const created = await api.createReminder(task.id, {
|
||||||
|
remind_at: new Date(newReminder).toISOString()
|
||||||
|
});
|
||||||
|
reminders = [...reminders, created];
|
||||||
|
newReminder = '';
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(e.fields?.remind_at ?? humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeReminder(reminder) {
|
||||||
|
const previous = reminders;
|
||||||
|
reminders = reminders.filter((r) => r.id !== reminder.id);
|
||||||
|
try {
|
||||||
|
await api.deleteReminder(reminder.id);
|
||||||
|
} catch (e) {
|
||||||
|
reminders = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event) {
|
||||||
|
if (event.key === 'Escape') onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
const kb = (bytes) =>
|
||||||
|
bytes < 1024 * 1024
|
||||||
|
? `${Math.max(1, Math.round(bytes / 1024))} kB`
|
||||||
|
: `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
|
||||||
|
const stamp = (iso) =>
|
||||||
|
new Date(iso).toLocaleString('da-DK', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window {onkeydown} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div class="scrim" onclick={onclose} role="presentation"></div>
|
||||||
|
|
||||||
|
<aside class="drawer" aria-label="Opgave">
|
||||||
|
<header>
|
||||||
|
<button class="close" onclick={onclose} aria-label="Luk">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
<div class="field">
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
class="title"
|
||||||
|
bind:value={content}
|
||||||
|
onblur={saveContent}
|
||||||
|
aria-label="Opgavens tekst"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="description">Beskrivelse</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
rows="4"
|
||||||
|
bind:value={description}
|
||||||
|
onblur={saveDescription}
|
||||||
|
placeholder="Detaljer, links, hvad der skal huskes"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="priority">Prioritet</label>
|
||||||
|
<select
|
||||||
|
id="priority"
|
||||||
|
bind:value={priority}
|
||||||
|
onchange={() => save({ priority: Number(priority) })}
|
||||||
|
>
|
||||||
|
<option value={1}>P1 — haster</option>
|
||||||
|
<option value={2}>P2</option>
|
||||||
|
<option value={3}>P3</option>
|
||||||
|
<option value={4}>Ingen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="due">Forfalder</label>
|
||||||
|
<input
|
||||||
|
id="due"
|
||||||
|
type="date"
|
||||||
|
bind:value={dueDate}
|
||||||
|
onchange={() => save({ due_date: dueDate })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="labels">Etiketter</label>
|
||||||
|
<input
|
||||||
|
id="labels"
|
||||||
|
bind:value={labels}
|
||||||
|
onblur={saveLabels}
|
||||||
|
placeholder="adskilt af komma"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if task.recurrence_text}
|
||||||
|
<p class="repeats">Gentages {task.recurrence_text}.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Undertasks</h3>
|
||||||
|
|
||||||
|
{#each subtasks as subtask (subtask.id)}
|
||||||
|
<div class="subtask" class:done={subtask.completed}>
|
||||||
|
<button
|
||||||
|
class="check"
|
||||||
|
class:checked={subtask.completed}
|
||||||
|
onclick={() => toggleSubtask(subtask)}
|
||||||
|
aria-label={subtask.completed ? 'Genåbn' : 'Markér som færdig'}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6 12.5l4 4 8-8.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="subtask-text">{subtask.content}</span>
|
||||||
|
<button class="remove" onclick={() => removeSubtask(subtask)} aria-label="Slet">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<form onsubmit={addSubtask}>
|
||||||
|
<input bind:value={newSubtask} placeholder="Tilføj en undertask" aria-label="Ny undertask" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Filer</h3>
|
||||||
|
|
||||||
|
{#each attachments as attachment (attachment.id)}
|
||||||
|
<div class="file">
|
||||||
|
<!-- Always a download, never rendered inline: an uploaded SVG shown
|
||||||
|
in place would run its own script on this origin. -->
|
||||||
|
<a href={attachment.url} download={attachment.filename}>{attachment.filename}</a>
|
||||||
|
<span class="size">{kb(attachment.size)}</span>
|
||||||
|
<button class="remove" onclick={() => removeAttachment(attachment)} aria-label="Slet">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<label class="upload">
|
||||||
|
<input type="file" onchange={upload} disabled={uploading} />
|
||||||
|
<span>{uploading ? 'Lægger op …' : 'Vedhæft en fil'}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Påmindelser</h3>
|
||||||
|
|
||||||
|
{#each reminders as reminder (reminder.id)}
|
||||||
|
<div class="reminder">
|
||||||
|
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
|
||||||
|
{#if reminder.sent}<span class="sent">sendt</span>{/if}
|
||||||
|
<button class="remove" onclick={() => removeReminder(reminder)} aria-label="Slet">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<form onsubmit={addReminder}>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={newReminder}
|
||||||
|
aria-label="Ny påmindelse"
|
||||||
|
/>
|
||||||
|
<button class="add" type="submit">Tilføj</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Kommentarer</h3>
|
||||||
|
|
||||||
|
{#each comments as comment (comment.id)}
|
||||||
|
<article class="comment">
|
||||||
|
<span class="avatar" style="background: {comment.user_color}">
|
||||||
|
{comment.user_name?.[0]?.toUpperCase() ?? '?'}
|
||||||
|
</span>
|
||||||
|
<div class="said">
|
||||||
|
<span class="who">
|
||||||
|
{comment.user_name}
|
||||||
|
<span class="when">{stamp(comment.created_at)}</span>
|
||||||
|
</span>
|
||||||
|
<p>{comment.body}</p>
|
||||||
|
{#each comment.attachments ?? [] as attachment (attachment.id)}
|
||||||
|
<a class="inline-file" href={attachment.url} download={attachment.filename}>
|
||||||
|
{attachment.filename}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if comment.user_id === app.user?.id}
|
||||||
|
<button class="remove" onclick={() => removeComment(comment)} aria-label="Slet">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<form onsubmit={addComment}>
|
||||||
|
<textarea
|
||||||
|
rows="2"
|
||||||
|
bind:value={newComment}
|
||||||
|
placeholder="Skriv en kommentar"
|
||||||
|
aria-label="Ny kommentar"
|
||||||
|
></textarea>
|
||||||
|
<button class="add" type="submit" disabled={posting}>Skriv</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 0.4);
|
||||||
|
z-index: 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 0 auto;
|
||||||
|
width: min(480px, 100vw);
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--ground);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: slide var(--medium) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide {
|
||||||
|
from {
|
||||||
|
transform: translateX(16px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.75;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: var(--s4) var(--s4) var(--s8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding-bottom: max(var(--s8), env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The title is the one field that should not look like a field until you are in
|
||||||
|
it — it is a heading that happens to be editable. */
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 560;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
padding-left: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title:focus {
|
||||||
|
background: var(--surface);
|
||||||
|
padding-left: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repeats {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding-top: var(--s3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask,
|
||||||
|
.file,
|
||||||
|
.reminder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask.done .subtask-text {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
flex: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1.5px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition:
|
||||||
|
background var(--fast) var(--ease),
|
||||||
|
border-color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check.checked {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check svg {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent-ink);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file a,
|
||||||
|
.inline-file {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file a:hover,
|
||||||
|
.inline-file:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file a {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size,
|
||||||
|
.sent,
|
||||||
|
.when {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder span:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
flex: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
flex: none;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: var(--s2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 560;
|
||||||
|
color: #fff;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.said {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.said p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.drawer {
|
||||||
|
width: 100vw;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -72,7 +72,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="body" onclick={() => onedit?.(task)}>
|
<!-- Opening the detail drawer is the default. `onedit` is for the callers that
|
||||||
|
want the click to mean something else. -->
|
||||||
|
<button class="body" onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}>
|
||||||
<span class="content">{task.content}</span>
|
<span class="content">{task.content}</span>
|
||||||
|
|
||||||
{#if task.description}
|
{#if task.description}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
|
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
|
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
// The navigation shortcuts are for the list. With a task open, "t" means the
|
||||||
|
// letter t — the drawer is a place you are, not a place you pass through.
|
||||||
|
if (app.detailId) return;
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'q':
|
case 'q':
|
||||||
@@ -111,6 +115,12 @@
|
|||||||
|
|
||||||
<CommandPalette bind:open={paletteOpen} />
|
<CommandPalette bind:open={paletteOpen} />
|
||||||
|
|
||||||
|
<!-- Mounted here rather than in each view, so a task opens the same way from
|
||||||
|
Today, a project, a label or a saved filter. -->
|
||||||
|
{#if app.detailTask}
|
||||||
|
<TaskDetail task={app.detailTask} onclose={() => app.closeDetail()} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Toasts are for things that failed after the interface already said they
|
<!-- Toasts are for things that failed after the interface already said they
|
||||||
had succeeded. Nothing else earns an interruption. -->
|
had succeeded. Nothing else earns an interruption. -->
|
||||||
<div class="toasts" role="status" aria-live="polite">
|
<div class="toasts" role="status" aria-live="polite">
|
||||||
|
|||||||
Reference in New Issue
Block a user