2026-08-17 10:39:56 +02:00
|
|
|
|
<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';
|
2026-08-20 20:29:03 +02:00
|
|
|
|
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
2026-08-18 09:05:02 +02:00
|
|
|
|
import { focusOnMount } from '$lib/focus.js';
|
2026-08-25 10:59:34 +02:00
|
|
|
|
import { t, tag } from '$lib/i18n.svelte.js';
|
2026-10-08 19:16:14 +02:00
|
|
|
|
import { REPEATS, reminderOffsetLabel } from '$lib/when.js';
|
2026-10-06 12:27:46 +02:00
|
|
|
|
import { readList } from '$lib/pastelist.js';
|
2026-08-17 10:39:56 +02:00
|
|
|
|
|
|
|
|
|
|
let { task, onclose } = $props();
|
|
|
|
|
|
|
|
|
|
|
|
let content = $state('');
|
|
|
|
|
|
let description = $state('');
|
|
|
|
|
|
let priority = $state(4);
|
|
|
|
|
|
let dueDate = $state('');
|
2026-08-20 20:29:03 +02:00
|
|
|
|
let dueTime = $state('');
|
2026-08-24 10:14:39 +02:00
|
|
|
|
// Length in minutes, as a string for the field. Only meaningful with a time —
|
|
|
|
|
|
// see the field below — so it is seeded and shown alongside the clock.
|
|
|
|
|
|
let duration = $state('');
|
2026-08-25 10:59:34 +02:00
|
|
|
|
let snoozeCustom = $state('');
|
|
|
|
|
|
|
|
|
|
|
|
const snoozed = $derived(!!task?.snoozed_until && new Date(task.snoozed_until) > new Date());
|
|
|
|
|
|
const snoozedText = $derived(
|
|
|
|
|
|
snoozed
|
|
|
|
|
|
? new Date(task.snoozed_until).toLocaleString(tag(), {
|
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
|
month: 'short',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit'
|
|
|
|
|
|
})
|
|
|
|
|
|
: ''
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
async function snoozeTo(iso) {
|
|
|
|
|
|
snoozeCustom = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await app.snooze(task.id, iso);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Quick picks, in local time, as ISO for the API. This evening is six; the
|
|
|
|
|
|
// morning ones are nine; next week is the coming Monday.
|
|
|
|
|
|
const atLocal = (d, h) => {
|
|
|
|
|
|
d.setHours(h, 0, 0, 0);
|
|
|
|
|
|
return d.toISOString();
|
|
|
|
|
|
};
|
|
|
|
|
|
const thisEvening = () => atLocal(new Date(), 18);
|
|
|
|
|
|
const tomorrowMorning = () => {
|
|
|
|
|
|
const d = new Date();
|
|
|
|
|
|
d.setDate(d.getDate() + 1);
|
|
|
|
|
|
return atLocal(d, 9);
|
|
|
|
|
|
};
|
|
|
|
|
|
const nextWeek = () => {
|
|
|
|
|
|
const d = new Date();
|
|
|
|
|
|
d.setDate(d.getDate() + (((8 - d.getDay()) % 7) || 7));
|
|
|
|
|
|
return atLocal(d, 9);
|
|
|
|
|
|
};
|
2026-08-17 10:39:56 +02:00
|
|
|
|
let labels = $state('');
|
2026-08-17 16:53:41 +02:00
|
|
|
|
let projectId = $state('');
|
|
|
|
|
|
let recurrence = $state('');
|
|
|
|
|
|
let assignee = $state('');
|
|
|
|
|
|
|
|
|
|
|
|
/** Who the task can be given to. Only ever more than one on a shared project. */
|
|
|
|
|
|
let members = $state([]);
|
|
|
|
|
|
|
2026-08-17 10:39:56 +02:00
|
|
|
|
let subtasks = $state([]);
|
|
|
|
|
|
let comments = $state([]);
|
|
|
|
|
|
let attachments = $state([]);
|
2026-08-19 18:59:34 +02:00
|
|
|
|
let notes = $state([]);
|
2026-08-17 10:39:56 +02:00
|
|
|
|
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 ?? '';
|
2026-08-20 20:29:03 +02:00
|
|
|
|
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
2026-08-24 10:14:39 +02:00
|
|
|
|
duration = task.duration_min ? String(task.duration_min) : '';
|
2026-08-17 10:39:56 +02:00
|
|
|
|
labels = (task.labels ?? []).join(', ');
|
2026-08-17 16:53:41 +02:00
|
|
|
|
projectId = task.project_id;
|
|
|
|
|
|
recurrence = task.recurrence_rule ?? '';
|
|
|
|
|
|
assignee = task.assignee_id ?? '';
|
|
|
|
|
|
|
|
|
|
|
|
members = [];
|
|
|
|
|
|
api.listMembers(task.project_id)
|
|
|
|
|
|
.then((r) => r.members.length > 1 && (members = r.members))
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
// A project you are not the owner of may refuse the member list.
|
|
|
|
|
|
// Then there is nobody to choose from, which is the same as none.
|
|
|
|
|
|
});
|
2026-08-17 10:39:56 +02:00
|
|
|
|
|
|
|
|
|
|
subtasks = [];
|
|
|
|
|
|
comments = [];
|
|
|
|
|
|
attachments = [];
|
2026-08-19 18:59:34 +02:00
|
|
|
|
notes = [];
|
2026-08-17 10:39:56 +02:00
|
|
|
|
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(() => {});
|
2026-08-19 18:59:34 +02:00
|
|
|
|
// Quietly, because a task with no notes is the ordinary case and a failure
|
|
|
|
|
|
// here must not put an error in front of somebody reading a task.
|
|
|
|
|
|
api.notesLinking('task', id)
|
|
|
|
|
|
.then((r) => id === seededId && (notes = r.notes ?? []))
|
|
|
|
|
|
.catch(() => {});
|
2026-08-17 10:39:56 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-18 09:05:02 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Sends a patch through the store, so the row behind the drawer updates too.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `task` can be null by the time this runs. The three fields below save on
|
|
|
|
|
|
* blur, and closing the drawer *is* a blur — the input is being unmounted, and
|
|
|
|
|
|
* the browser fires blur on the way out. If the task left `app.tasks` in the
|
|
|
|
|
|
* same moment (it was completed, it was moved, another browser deleted it),
|
|
|
|
|
|
* `app.detailTask` is already null and this is a handler for a task that is no
|
|
|
|
|
|
* longer there.
|
|
|
|
|
|
*
|
|
|
|
|
|
* It threw `Cannot read properties of null` and stopped the page. Nothing to
|
|
|
|
|
|
* save is not an error; it is the ordinary end of an edit nobody made.
|
|
|
|
|
|
*/
|
2026-08-17 10:39:56 +02:00
|
|
|
|
async function save(patch) {
|
2026-08-18 09:05:02 +02:00
|
|
|
|
if (!task) return;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
await app.update(task.id, patch);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 16:53:41 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Saves the title, reading it the way quick add would.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Typing "i morgen kl. 14" into a title used to leave it as those words. The
|
|
|
|
|
|
* same sentence in the quick-add box becomes a date, which made where you
|
|
|
|
|
|
* typed it decide what it meant.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The parse runs on the server — the grammar is Danish and English and lives
|
|
|
|
|
|
* there — and is only applied when it actually consumed something. What it
|
|
|
|
|
|
* took is said out loud, because a title that edits itself without a word is
|
|
|
|
|
|
* worse than one that does nothing.
|
|
|
|
|
|
*/
|
2026-09-15 20:12:28 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Lader et felt være lige så højt som sin tekst.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `height: auto` først, og så `scrollHeight`: uden nulstillingen kan feltet kun
|
|
|
|
|
|
* vokse, for scrollHeight på et felt, der allerede er for højt, er dets egen
|
|
|
|
|
|
* højde.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function grow(node) {
|
|
|
|
|
|
const fit = () => {
|
|
|
|
|
|
node.style.height = 'auto';
|
|
|
|
|
|
node.style.height = `${node.scrollHeight}px`;
|
|
|
|
|
|
};
|
|
|
|
|
|
fit();
|
|
|
|
|
|
node.addEventListener('input', fit);
|
|
|
|
|
|
// Bredden skifter, når ruden gør — og en tekst, der fyldte to linjer, fylder
|
|
|
|
|
|
// tre på en smallere skærm uden at der er rørt ved den.
|
|
|
|
|
|
const watch = new ResizeObserver(fit);
|
|
|
|
|
|
watch.observe(node);
|
|
|
|
|
|
return {
|
|
|
|
|
|
destroy() {
|
|
|
|
|
|
node.removeEventListener('input', fit);
|
|
|
|
|
|
watch.disconnect();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 16:53:41 +02:00
|
|
|
|
async function saveContent() {
|
2026-08-18 09:05:02 +02:00
|
|
|
|
if (!task) return;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
const trimmed = content.trim();
|
|
|
|
|
|
if (!trimmed || trimmed === task.content) {
|
|
|
|
|
|
content = task.content;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-17 16:53:41 +02:00
|
|
|
|
|
|
|
|
|
|
let parsed = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
parsed = await api.quickAddPreview(trimmed);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Offline, or the parser refused. Saving the words is still right.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const patch = { content: trimmed };
|
|
|
|
|
|
const took = [];
|
|
|
|
|
|
if (parsed && parsed.content && parsed.content !== trimmed) {
|
|
|
|
|
|
patch.content = parsed.content;
|
|
|
|
|
|
if (parsed.due_date) {
|
|
|
|
|
|
patch.due_date = parsed.due_date;
|
|
|
|
|
|
took.push(parsed.due_date);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (parsed.priority && parsed.priority < 4) {
|
|
|
|
|
|
patch.priority = parsed.priority;
|
|
|
|
|
|
took.push(`P${parsed.priority}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (parsed.recurrence) {
|
|
|
|
|
|
patch.recurrence_rule = parsed.recurrence;
|
|
|
|
|
|
took.push('gentagelse');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (parsed.labels?.length) {
|
|
|
|
|
|
patch.labels = [...new Set([...(task.labels ?? []), ...parsed.labels])];
|
|
|
|
|
|
took.push(parsed.labels.map((l) => `@${l}`).join(' '));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await save(patch);
|
|
|
|
|
|
|
|
|
|
|
|
// The fields are seeded once per task so a save cannot overwrite what
|
|
|
|
|
|
// somebody is typing. That rule has to bend exactly here: this save
|
|
|
|
|
|
// changed the very fields on screen, and leaving them showing what was
|
|
|
|
|
|
// typed while the toast reports something else is the worst of both.
|
|
|
|
|
|
if (took.length) {
|
|
|
|
|
|
content = patch.content;
|
|
|
|
|
|
if (patch.due_date) dueDate = patch.due_date;
|
|
|
|
|
|
if (patch.priority) priority = patch.priority;
|
|
|
|
|
|
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
|
|
|
|
|
|
if (patch.labels) labels = patch.labels.join(', ');
|
2026-08-18 09:46:08 +02:00
|
|
|
|
app.toast(t('detail.readAs', { what: took.join(', ') }));
|
2026-08-17 16:53:41 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveProject() {
|
|
|
|
|
|
if (projectId === task.project_id) return;
|
|
|
|
|
|
await save({ project_id: projectId });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveRecurrence() {
|
|
|
|
|
|
if (recurrence === (task.recurrence_rule ?? '')) return;
|
|
|
|
|
|
await save({ recurrence_rule: recurrence });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveAssignee() {
|
|
|
|
|
|
if (assignee === (task.assignee_id ?? '')) return;
|
|
|
|
|
|
await save({ assignee_id: assignee });
|
2026-08-17 10:39:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveDescription() {
|
2026-08-18 09:05:02 +02:00
|
|
|
|
if (!task || description === (task.description ?? '')) return;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
save({ description });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveLabels() {
|
2026-08-18 09:05:02 +02:00
|
|
|
|
if (!task) return;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
const list = labels
|
|
|
|
|
|
.split(',')
|
|
|
|
|
|
.map((l) => l.trim())
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
if (list.join() === (task.labels ?? []).join()) return;
|
|
|
|
|
|
save({ labels: list });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- sub-tasks ----------------------------------------------------------------
|
|
|
|
|
|
|
2026-10-06 12:27:46 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* En liste indsat i undertask-feltet bliver til undertasks under DEN her opgave.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Det er den anden halvdel af "enten opgaver eller undertasks": hvor man
|
|
|
|
|
|
* indsætter, afgør hvad linjerne bliver, uden at nogen skal vælge noget i en
|
|
|
|
|
|
* menu. Indrykning i det indsatte giver stadig et niveau mere, så en liste med
|
|
|
|
|
|
* punkter under punkter kommer ind som den stod.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Som i quick-add vises den, før den oprettes — og af samme grund.
|
|
|
|
|
|
*/
|
|
|
|
|
|
let pastedSubtasks = $state([]);
|
|
|
|
|
|
|
|
|
|
|
|
function onPasteSubtasks(event) {
|
|
|
|
|
|
const pasted = event.clipboardData?.getData('text/plain') ?? '';
|
|
|
|
|
|
if (!pasted.includes('\n')) return;
|
|
|
|
|
|
const rows = readList(pasted);
|
|
|
|
|
|
if (rows.length < 2) return;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
pastedSubtasks = rows;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function createPastedSubtasks() {
|
|
|
|
|
|
if (addingPasted) return;
|
|
|
|
|
|
addingPasted = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Niveau nul hænger under den åbne opgave; dybere niveauer hænger under den
|
|
|
|
|
|
// seneste linje ovenfor, som i quick-add.
|
|
|
|
|
|
const parents = [task.id];
|
|
|
|
|
|
let made = 0;
|
|
|
|
|
|
for (const row of pastedSubtasks) {
|
|
|
|
|
|
const created = await app.quickAdd(row.text, task.project_id, undefined, parents[row.depth]);
|
|
|
|
|
|
if (!created) break;
|
|
|
|
|
|
parents[row.depth + 1] = created.id;
|
|
|
|
|
|
parents.length = row.depth + 2;
|
|
|
|
|
|
if (row.done) await app.complete(created.id);
|
|
|
|
|
|
// Kun dem, der hører direkte under den åbne opgave, står i listen her —
|
|
|
|
|
|
// resten hører under deres egen.
|
|
|
|
|
|
if (row.depth === 0) subtasks = [...subtasks, created];
|
|
|
|
|
|
made++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addingPasted = false;
|
|
|
|
|
|
pastedSubtasks = pastedSubtasks.slice(made);
|
|
|
|
|
|
countSubtasks();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let addingPasted = $state(false);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Rækkens tæller, når en undertask er kommet til eller er blevet lukket.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Tallene bor på FORÆLDEREN og sendes med hver opgave, så listen kan sige
|
|
|
|
|
|
* "1/4" uden at åbne noget. Serveren regner dem ud, når opgaverne hentes — den
|
|
|
|
|
|
* sender ikke forælderen igen, fordi et barn blev lavet. Så uden det her stod
|
|
|
|
|
|
* rækken bagved og sagde 1/2, mens ruden foran viste fire undertasks.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Samme regel som resten af butikken: en lokal handling slår igennem med det
|
|
|
|
|
|
* samme og bliver afstemt bagefter. Næste hentning henter serverens tal.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function countSubtasks() {
|
|
|
|
|
|
app.upsert({
|
|
|
|
|
|
...task,
|
|
|
|
|
|
subtask_count: subtasks.length,
|
|
|
|
|
|
subtask_done: subtasks.filter((s) => s.completed).length
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:39:56 +02:00
|
|
|
|
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];
|
2026-10-06 12:27:46 +02:00
|
|
|
|
countSubtasks();
|
2026-08-17 10:39:56 +02:00
|
|
|
|
} 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) {
|
2026-08-18 13:28:44 +02:00
|
|
|
|
if (!confirm(t('detail.deleteComment'))) return;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<aside class="drawer" aria-label={t('detail.task')}>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<header>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<button class="close" onclick={onclose} aria-label={t('detail.close')}>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<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">
|
2026-09-15 20:12:28 +02:00
|
|
|
|
<!-- Et felt frem for en linje. En opgavetitel er tit længere end en telefon
|
|
|
|
|
|
er bred — "tado°: Your Smart Thermostat needs new batteries" er en
|
|
|
|
|
|
almindelig en — og et <input> kan ikke ombryde: det ruller, og så står
|
|
|
|
|
|
halvdelen af overskriften uden for kanten. -->
|
|
|
|
|
|
<textarea
|
2026-08-17 10:39:56 +02:00
|
|
|
|
class="title"
|
2026-09-15 20:12:28 +02:00
|
|
|
|
rows="1"
|
2026-08-18 09:05:02 +02:00
|
|
|
|
use:focusOnMount
|
2026-09-15 20:12:28 +02:00
|
|
|
|
use:grow
|
2026-08-17 10:39:56 +02:00
|
|
|
|
bind:value={content}
|
|
|
|
|
|
onblur={saveContent}
|
2026-09-15 20:12:28 +02:00
|
|
|
|
onkeydown={(e) => {
|
|
|
|
|
|
// Retur gemmer frem for at lave en ny linje. En titel er én linje,
|
|
|
|
|
|
// uanset hvor mange den fylder på skærmen.
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.currentTarget.blur();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-08-18 09:46:08 +02:00
|
|
|
|
aria-label={t('detail.text')}
|
2026-09-15 20:12:28 +02:00
|
|
|
|
></textarea>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<label for="description">{t('detail.description')}</label>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<textarea
|
|
|
|
|
|
id="description"
|
|
|
|
|
|
rows="4"
|
|
|
|
|
|
bind:value={description}
|
|
|
|
|
|
onblur={saveDescription}
|
2026-08-18 09:46:08 +02:00
|
|
|
|
placeholder={t('detail.descriptionPlaceholder')}
|
2026-08-17 10:39:56 +02:00
|
|
|
|
></textarea>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="grid">
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<label for="priority">{t('detail.priority')}</label>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<select
|
|
|
|
|
|
id="priority"
|
|
|
|
|
|
bind:value={priority}
|
|
|
|
|
|
onchange={() => save({ priority: Number(priority) })}
|
|
|
|
|
|
>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<option value={1}>{t('detail.p1')}</option>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<option value={2}>P2</option>
|
|
|
|
|
|
<option value={3}>P3</option>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<option value={4}>{t('detail.none')}</option>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-20 20:29:03 +02:00
|
|
|
|
<!--
|
|
|
|
|
|
Dato og klokkeslæt gemmes sammen, altid.
|
|
|
|
|
|
|
|
|
|
|
|
Serveren læser de to felter som ét: kommer datoen uden en tid, *ryddes*
|
|
|
|
|
|
tiden. Så et gem af datoen alene tog klokkeslættet med sig — man rettede
|
|
|
|
|
|
dagen og mistede timen, uden at noget sagde det.
|
|
|
|
|
|
|
|
|
|
|
|
Tiden er tom, indtil nogen sætter den. En opgave uden klokkeslæt er det
|
|
|
|
|
|
almindelige, og et felt, der åbner på 00:00, er et felt, der påstår, at
|
|
|
|
|
|
noget skal ske ved midnat.
|
|
|
|
|
|
-->
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<label for="due">{t('detail.due')}</label>
|
2026-08-20 20:29:03 +02:00
|
|
|
|
<div class="due">
|
|
|
|
|
|
<input
|
|
|
|
|
|
id="due"
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
bind:value={dueDate}
|
|
|
|
|
|
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="time"
|
|
|
|
|
|
aria-label={t('detail.dueTime')}
|
|
|
|
|
|
bind:value={dueTime}
|
|
|
|
|
|
disabled={!dueDate}
|
|
|
|
|
|
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
|
|
|
|
|
/>
|
2026-08-24 10:14:39 +02:00
|
|
|
|
<!-- Length in minutes, and only with a time to hang it on: a duration
|
|
|
|
|
|
without a start is a length of nothing, and the calendar has
|
|
|
|
|
|
nowhere to draw it. Empty clears it. -->
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
class="dur"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
step="15"
|
|
|
|
|
|
aria-label={t('detail.duration')}
|
|
|
|
|
|
placeholder={t('detail.durationShort')}
|
|
|
|
|
|
bind:value={duration}
|
|
|
|
|
|
disabled={!dueTime}
|
|
|
|
|
|
onchange={() =>
|
|
|
|
|
|
save({ duration_min: duration === '' ? 0 : Math.max(0, Math.round(Number(duration))) })}
|
|
|
|
|
|
/>
|
2026-08-20 20:29:03 +02:00
|
|
|
|
</div>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-25 10:59:34 +02:00
|
|
|
|
<!-- Snooze: park it out of the way until a moment, without touching when it is
|
|
|
|
|
|
due. While snoozed the row greys and sinks to the bottom of its list. -->
|
|
|
|
|
|
<div class="field">
|
|
|
|
|
|
<span class="field-label">{t('detail.snooze')}</span>
|
|
|
|
|
|
{#if snoozed}
|
|
|
|
|
|
<div class="snoozed-now">
|
|
|
|
|
|
<span>{t('task.snoozedUntil', { when: snoozedText })}</span>
|
|
|
|
|
|
<button class="snooze-btn" onclick={() => snoozeTo('')}>{t('detail.wake')}</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<div class="snooze-row">
|
|
|
|
|
|
<button class="snooze-btn" onclick={() => snoozeTo(thisEvening())}>{t('detail.thisEvening')}</button>
|
|
|
|
|
|
<button class="snooze-btn" onclick={() => snoozeTo(tomorrowMorning())}>{t('detail.tomorrow')}</button>
|
|
|
|
|
|
<button class="snooze-btn" onclick={() => snoozeTo(nextWeek())}>{t('detail.nextWeek')}</button>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="datetime-local"
|
|
|
|
|
|
class="snooze-custom"
|
|
|
|
|
|
aria-label={t('detail.snoozeCustom')}
|
|
|
|
|
|
bind:value={snoozeCustom}
|
|
|
|
|
|
onchange={() => snoozeCustom && snoozeTo(new Date(snoozeCustom).toISOString())}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<label for="labels">{t('detail.labels')}</label>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<input
|
|
|
|
|
|
id="labels"
|
|
|
|
|
|
bind:value={labels}
|
|
|
|
|
|
onblur={saveLabels}
|
2026-08-18 09:46:08 +02:00
|
|
|
|
placeholder={t('detail.labelsPlaceholder')}
|
2026-08-17 10:39:56 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-17 16:53:41 +02:00
|
|
|
|
<div class="grid">
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<label for="project">{t('detail.project')}</label>
|
2026-08-17 16:53:41 +02:00
|
|
|
|
<select id="project" bind:value={projectId} onchange={saveProject}>
|
|
|
|
|
|
{#each app.projects as project (project.id)}
|
2026-08-18 12:02:56 +02:00
|
|
|
|
<option value={project.id}>{projectName(project)}</option>
|
2026-08-17 16:53:41 +02:00
|
|
|
|
{/each}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<label for="repeat">{t('detail.repeats')}</label>
|
2026-08-17 16:53:41 +02:00
|
|
|
|
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
|
|
|
|
|
{#each REPEATS as option (option.rule)}
|
2026-08-20 07:23:42 +02:00
|
|
|
|
<option value={option.rule}>{t(option.label)}</option>
|
2026-08-17 16:53:41 +02:00
|
|
|
|
{/each}
|
|
|
|
|
|
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
|
|
|
|
|
showing it rather than snapping to the nearest preset is the
|
|
|
|
|
|
difference between a menu and a menu that quietly edits. -->
|
|
|
|
|
|
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
2026-08-20 07:23:42 +02:00
|
|
|
|
<option value={recurrence}>{task.recurrence_text ?? t('detail.asWritten')}</option>
|
2026-08-17 16:53:41 +02:00
|
|
|
|
{/if}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{#if members.length > 1}
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<label for="assignee">{t('detail.assignee')}</label>
|
2026-08-17 16:53:41 +02:00
|
|
|
|
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<option value="">{t('detail.none')}</option>
|
2026-08-17 16:53:41 +02:00
|
|
|
|
{#each members as member (member.user_id)}
|
|
|
|
|
|
<option value={member.user_id}>{member.name}</option>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
<section>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<h3>{t('detail.subtasks')}</h3>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
|
|
|
|
|
|
{#each subtasks as subtask (subtask.id)}
|
|
|
|
|
|
<div class="subtask" class:done={subtask.completed}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="check"
|
|
|
|
|
|
class:checked={subtask.completed}
|
|
|
|
|
|
onclick={() => toggleSubtask(subtask)}
|
2026-08-18 09:46:08 +02:00
|
|
|
|
aria-label={subtask.completed ? t('detail.reopen') : t('task.complete')}
|
2026-08-17 10:39:56 +02:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<button class="remove" onclick={() => removeSubtask(subtask)} aria-label={t('detail.delete')}>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
|
|
|
|
|
|
<form onsubmit={addSubtask}>
|
2026-10-06 12:27:46 +02:00
|
|
|
|
<input
|
|
|
|
|
|
bind:value={newSubtask}
|
|
|
|
|
|
onpaste={onPasteSubtasks}
|
|
|
|
|
|
placeholder={t('detail.addSubtask')}
|
|
|
|
|
|
aria-label={t('detail.newSubtask')}
|
|
|
|
|
|
/>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
</form>
|
2026-10-06 12:27:46 +02:00
|
|
|
|
|
|
|
|
|
|
{#if pastedSubtasks.length}
|
|
|
|
|
|
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
|
|
|
|
|
|
<ul>
|
|
|
|
|
|
{#each pastedSubtasks as row, i (i)}
|
|
|
|
|
|
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<div class="pasted-actions">
|
|
|
|
|
|
<button type="button" disabled={addingPasted} onclick={createPastedSubtasks}>
|
|
|
|
|
|
{t('task.pastedAdd', { n: String(pastedSubtasks.length) })}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" class="plain" onclick={() => (pastedSubtasks = [])}>
|
|
|
|
|
|
{t('task.pastedCancel')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
2026-08-17 10:39:56 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<h3>{t('detail.files')}</h3>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
|
|
|
|
|
|
{#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>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<button class="remove" onclick={() => removeAttachment(attachment)} aria-label={t('detail.delete')}>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
|
|
|
|
|
|
<label class="upload">
|
|
|
|
|
|
<input type="file" onchange={upload} disabled={uploading} />
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<span>{uploading ? t('detail.uploading') : t('detail.attachFile')}</span>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
</label>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<h3>{t('detail.reminders')}</h3>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
|
|
|
|
|
|
{#each reminders as reminder (reminder.id)}
|
|
|
|
|
|
<div class="reminder">
|
2026-10-08 19:16:14 +02:00
|
|
|
|
<span>{reminder.remind_at
|
|
|
|
|
|
? stamp(reminder.remind_at)
|
|
|
|
|
|
: reminderOffsetLabel(reminder.offset_min)}</span>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
{#if reminder.sent}<span class="sent">{t('detail.sent')}</span>{/if}
|
|
|
|
|
|
<button class="remove" onclick={() => removeReminder(reminder)} aria-label={t('detail.delete')}>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
|
|
|
|
|
|
<form onsubmit={addReminder}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="datetime-local"
|
|
|
|
|
|
bind:value={newReminder}
|
2026-08-18 09:46:08 +02:00
|
|
|
|
aria-label={t('detail.newReminder')}
|
2026-08-17 10:39:56 +02:00
|
|
|
|
/>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<button class="add" type="submit">{t('detail.add')}</button>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
</form>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-08-19 18:59:34 +02:00
|
|
|
|
<!-- What has been written about this task.
|
|
|
|
|
|
The other direction of the same link: a note that mentions a task shows
|
|
|
|
|
|
it, and this is the task showing the note. Without it the connection only
|
|
|
|
|
|
works if you happen to be reading the note — which is the half nobody
|
|
|
|
|
|
needs help with. -->
|
|
|
|
|
|
{#if notes.length}
|
|
|
|
|
|
<section class="linked-notes">
|
|
|
|
|
|
<h3>{t('notes.title')}</h3>
|
|
|
|
|
|
{#each notes as note (note.id)}
|
|
|
|
|
|
<a class="linked-note" href="/noter?note={note.id}">
|
|
|
|
|
|
<strong>{note.title || t('notes.untitled')}</strong>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-08-17 10:39:56 +02:00
|
|
|
|
<section>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<h3>{t('detail.comments')}</h3>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
|
|
|
|
|
|
{#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}
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<button class="remove" onclick={() => removeComment(comment)} aria-label={t('detail.delete')}>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</article>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
|
|
|
|
|
|
<form onsubmit={addComment}>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
rows="2"
|
|
|
|
|
|
bind:value={newComment}
|
2026-08-18 09:46:08 +02:00
|
|
|
|
placeholder={t('detail.writeComment')}
|
|
|
|
|
|
aria-label={t('detail.newComment')}
|
2026-08-17 10:39:56 +02:00
|
|
|
|
></textarea>
|
2026-08-18 09:46:08 +02:00
|
|
|
|
<button class="add" type="submit" disabled={posting}>{t('detail.post')}</button>
|
2026-08-17 10:39:56 +02:00
|
|
|
|
</form>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
2026-10-06 12:27:46 +02:00
|
|
|
|
/* Den indsatte liste, før den bliver til undertasks. Samme form som i
|
|
|
|
|
|
quick-add — det er den samme beslutning, stillet samme sted i forløbet. */
|
|
|
|
|
|
.pasted {
|
|
|
|
|
|
margin-top: var(--s2);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
padding: var(--s2);
|
|
|
|
|
|
}
|
|
|
|
|
|
.pasted ul {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0 0 var(--s2);
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
max-height: 30vh;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pasted li {
|
|
|
|
|
|
padding: 2px 0 2px calc(var(--depth) * var(--s4));
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
.pasted li.done {
|
|
|
|
|
|
text-decoration: line-through;
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.pasted-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pasted-actions .plain {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 18:59:34 +02:00
|
|
|
|
.linked-note {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
padding: var(--s2);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.linked-note:hover {
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.linked-note strong {
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
font-weight: 560;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:39:56 +02:00
|
|
|
|
.scrim {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
background: rgb(0 0 0 / 0.4);
|
|
|
|
|
|
z-index: 70;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0 0 0 auto;
|
2026-08-17 23:24:49 +02:00
|
|
|
|
/* `100%` rather than `100vw`. For a fixed element a percentage resolves
|
|
|
|
|
|
against the initial containing block, which excludes the scrollbar gutter
|
|
|
|
|
|
— `100vw` does not, so the drawer was a few pixels wider than the page and
|
|
|
|
|
|
the whole document could be pushed sideways. */
|
|
|
|
|
|
width: min(480px, 100%);
|
|
|
|
|
|
max-width: 100%;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
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;
|
2026-08-17 23:24:49 +02:00
|
|
|
|
min-width: 0;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
overflow-y: auto;
|
2026-08-17 23:24:49 +02:00
|
|
|
|
/* Nothing inside may widen the drawer. A pasted URL in a comment or a long
|
|
|
|
|
|
unbroken word would otherwise stretch the flex column and take the page
|
|
|
|
|
|
with it. */
|
|
|
|
|
|
overflow-x: hidden;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 20:29:03 +02:00
|
|
|
|
/* Dato og tid på én linje: de er ét svar på ét spørgsmål, og tiden er den
|
|
|
|
|
|
smalle halvdel af det. */
|
|
|
|
|
|
.due {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.due input[type='date'] {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.due input[type='time'] {
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
width: 7.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 10:14:39 +02:00
|
|
|
|
.due input.dur {
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
width: 5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 20:29:03 +02:00
|
|
|
|
.due input:disabled {
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:39:56 +02:00
|
|
|
|
.field {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 10:59:34 +02:00
|
|
|
|
label,
|
|
|
|
|
|
.field-label {
|
2026-08-17 10:39:56 +02:00
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 10:59:34 +02:00
|
|
|
|
.snooze-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: var(--s1);
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.snooze-btn {
|
|
|
|
|
|
width: auto;
|
|
|
|
|
|
padding: var(--s1) var(--s2);
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.snooze-btn:hover {
|
|
|
|
|
|
background: var(--surface-raised);
|
|
|
|
|
|
}
|
|
|
|
|
|
.snooze-custom {
|
|
|
|
|
|
width: auto;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 9rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.snoozed-now {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:39:56 +02:00
|
|
|
|
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;
|
2026-09-15 20:12:28 +02:00
|
|
|
|
/* En beskrivelse er tit et klippet stykke mail med en URL i. Et <textarea>
|
|
|
|
|
|
ombryder ved mellemrum, og en URL har ingen — så feltet rullede vandret,
|
|
|
|
|
|
og på en telefon stod teksten forskudt med venstre kant klippet af. Det
|
|
|
|
|
|
ser ikke ud som rulning; det ser ud som manglende bogstaver.
|
|
|
|
|
|
|
|
|
|
|
|
`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan
|
|
|
|
|
|
stå på en linje alene, mens den anden bryder ved først givne lejlighed og
|
|
|
|
|
|
delte "GarageRisteriet" midt i en stavelse. Se h1 på projektets side — den
|
|
|
|
|
|
lektion er betalt én gang. */
|
|
|
|
|
|
overflow-wrap: break-word;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 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;
|
2026-09-15 20:12:28 +02:00
|
|
|
|
/* Højden sættes af `grow`, så håndtaget ville love noget, det ikke kan
|
|
|
|
|
|
holde: trækker man i det, retter det næste bogstav det tilbage. */
|
|
|
|
|
|
resize: none;
|
|
|
|
|
|
overflow: hidden;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-08-17 23:24:49 +02:00
|
|
|
|
/* Full width, stated as four insets rather than a width: the box is then
|
|
|
|
|
|
defined by the viewport's edges and cannot be a rounding error wider than
|
|
|
|
|
|
the thing it is pinned to. */
|
2026-08-17 10:39:56 +02:00
|
|
|
|
.drawer {
|
2026-08-17 23:24:49 +02:00
|
|
|
|
inset: 0;
|
|
|
|
|
|
width: auto;
|
2026-08-17 10:39:56 +02:00
|
|
|
|
border-left: 0;
|
|
|
|
|
|
}
|
2026-08-27 12:24:29 +02:00
|
|
|
|
|
|
|
|
|
|
/* Two columns is half a phone each: Prioritet collapses to "Ing" and the
|
|
|
|
|
|
due-row (date + fixed-width time + duration) runs off the right edge. One
|
|
|
|
|
|
column per field gives each its full width; the due-row wraps if even that
|
|
|
|
|
|
is not enough. */
|
|
|
|
|
|
.grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.due {
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.due input[type='date'] {
|
|
|
|
|
|
flex: 1 1 100%;
|
|
|
|
|
|
}
|
2026-08-17 10:39:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|