2026-08-17 00:03:20 +02:00
|
|
|
|
<script>
|
|
|
|
|
|
/** One project: its sections, its tasks, and who it is shared with. */
|
|
|
|
|
|
import { page } from '$app/stores';
|
2026-08-17 14:59:51 +02:00
|
|
|
|
import { goto } from '$app/navigation';
|
2026-08-17 00:03:20 +02:00
|
|
|
|
import { api, humanMessage } from '$lib/api.js';
|
|
|
|
|
|
import { app } from '$lib/stores.svelte.js';
|
2026-08-17 21:50:20 +02:00
|
|
|
|
import { COLORS, colorVar } from '$lib/colors.js';
|
2026-08-18 09:04:12 +02:00
|
|
|
|
import { eventName, eventDetail } from '$lib/events.js';
|
2026-08-17 22:04:55 +02:00
|
|
|
|
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
2026-08-17 10:40:05 +02:00
|
|
|
|
import TaskList from '$lib/components/TaskList.svelte';
|
2026-08-17 00:03:20 +02:00
|
|
|
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
2026-08-17 07:10:51 +02:00
|
|
|
|
import BoardView from '$lib/components/BoardView.svelte';
|
|
|
|
|
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
2026-08-18 09:04:12 +02:00
|
|
|
|
import { focusOnMount } from '$lib/focus.js';
|
2026-08-17 00:03:20 +02:00
|
|
|
|
|
|
|
|
|
|
let project = $state(null);
|
2026-08-17 22:04:55 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* loading | ready | denied | failed.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Four states rather than "is project null", which is what this was: the page
|
|
|
|
|
|
* showed "Projektet findes ikke, eller du har ikke adgang til det" before the
|
|
|
|
|
|
* first request had even returned, and again if it failed for any reason at
|
|
|
|
|
|
* all. A slow load, a dropped connection, a server being restarted underneath
|
|
|
|
|
|
* you and a project you genuinely cannot see were one message — and it never
|
|
|
|
|
|
* asked again, so the wrong one stayed until somebody reloaded.
|
|
|
|
|
|
*/
|
|
|
|
|
|
let status = $state('loading');
|
2026-08-17 00:03:20 +02:00
|
|
|
|
let sections = $state([]);
|
|
|
|
|
|
let members = $state([]);
|
|
|
|
|
|
let showShare = $state(false);
|
2026-08-17 22:31:43 +02:00
|
|
|
|
let showLog = $state(false);
|
|
|
|
|
|
let activity = $state([]);
|
2026-08-17 00:03:20 +02:00
|
|
|
|
let inviteEmail = $state('');
|
|
|
|
|
|
let inviteRole = $state('editor');
|
|
|
|
|
|
let inviteLink = $state('');
|
|
|
|
|
|
let inviteError = $state('');
|
|
|
|
|
|
|
|
|
|
|
|
let id = $derived($page.params.id);
|
|
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
2026-08-17 22:04:55 +02:00
|
|
|
|
load(id);
|
2026-08-17 00:03:20 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-17 22:04:55 +02:00
|
|
|
|
async function load(projectId) {
|
|
|
|
|
|
if (!projectId) return;
|
|
|
|
|
|
status = 'loading';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [p, s] = await Promise.all([
|
|
|
|
|
|
api.getProject(projectId),
|
|
|
|
|
|
api.listSections(projectId),
|
|
|
|
|
|
app.loadTasks({ project_id: projectId })
|
|
|
|
|
|
]);
|
|
|
|
|
|
project = p;
|
|
|
|
|
|
sections = s.sections;
|
|
|
|
|
|
status = 'ready';
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
project = null;
|
|
|
|
|
|
// 404 is the app's answer for both "no such project" and "not yours" —
|
|
|
|
|
|
// deliberately, so probing ids teaches nothing. Anything else is the
|
|
|
|
|
|
// request itself having failed, which is worth saying differently and
|
|
|
|
|
|
// worth offering to try again.
|
|
|
|
|
|
status = e?.status === 404 ? 'denied' : 'failed';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
$effect(() => {
|
|
|
|
|
|
if (showShare && project) {
|
|
|
|
|
|
api.listMembers(project.id).then((r) => (members = r.members));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-17 22:31:43 +02:00
|
|
|
|
// Loaded when it is opened rather than with the page: it is a record you go
|
|
|
|
|
|
// looking for, not something every visit should pay for.
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
|
if (showLog && project) {
|
|
|
|
|
|
api.activity(project.id).then((r) => (activity = r.activity)).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function when(iso) {
|
|
|
|
|
|
const then = new Date(iso);
|
|
|
|
|
|
const seconds = Math.round((Date.now() - then) / 1000);
|
|
|
|
|
|
if (seconds < 60) return 'lige nu';
|
|
|
|
|
|
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
|
|
|
|
|
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
|
|
|
|
|
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
|
|
|
|
|
|
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 07:10:51 +02:00
|
|
|
|
// The chosen view is kept locally as well as on the project, so switching is
|
|
|
|
|
|
// instant and only persists once the request lands.
|
|
|
|
|
|
let view = $state(null);
|
|
|
|
|
|
let mode = $derived(view ?? project?.view_mode ?? 'list');
|
|
|
|
|
|
|
|
|
|
|
|
async function setView(next) {
|
|
|
|
|
|
view = next;
|
|
|
|
|
|
if (project?.role === 'owner') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.updateProject(project.id, { view_mode: next });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// A view that did not persist is a small thing; it still switched.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
|
|
|
|
|
|
let isOwner = $derived(project?.role === 'owner');
|
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
|
// Filtered by project as well as by state. The store holds whatever the last
|
|
|
|
|
|
// view loaded, and a task can now leave this project while you are looking at
|
|
|
|
|
|
// it — dropped on another project in the sidebar, or moved by somebody else —
|
|
|
|
|
|
// so "what the page asked for" is not the same as "what is in the store".
|
|
|
|
|
|
let open = $derived(
|
|
|
|
|
|
app.tasks.filter((t) => !t.completed && !t.parent_id && t.project_id === project?.id)
|
|
|
|
|
|
);
|
2026-08-17 00:03:20 +02:00
|
|
|
|
let unsectioned = $derived(open.filter((t) => !t.section_id));
|
|
|
|
|
|
|
2026-08-17 15:07:15 +02:00
|
|
|
|
let editing = $state(false);
|
2026-08-17 21:50:20 +02:00
|
|
|
|
let choosingColor = $state(false);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The colour is written to the store as well as here: the sidebar reads its own
|
|
|
|
|
|
* copy of the project list, and a dot that only changed on this page would be
|
|
|
|
|
|
* the same colour in the two places you look at it.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function setColor(color) {
|
|
|
|
|
|
choosingColor = false;
|
|
|
|
|
|
project = { ...project, color };
|
|
|
|
|
|
await app.setProjectColor(project.id, color);
|
|
|
|
|
|
}
|
2026-08-17 15:07:15 +02:00
|
|
|
|
|
2026-08-17 14:59:51 +02:00
|
|
|
|
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
|
|
|
|
|
|
async function rename(input) {
|
|
|
|
|
|
const name = input.value.trim();
|
2026-08-17 15:07:15 +02:00
|
|
|
|
editing = false;
|
|
|
|
|
|
if (!name || name === project.name) return;
|
|
|
|
|
|
|
2026-08-17 14:59:51 +02:00
|
|
|
|
try {
|
|
|
|
|
|
project = await api.updateProject(project.id, { name });
|
|
|
|
|
|
// The sidebar reads its own copy, so it has to be told.
|
|
|
|
|
|
await app.refreshProjects();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function remove() {
|
|
|
|
|
|
const days = 30;
|
|
|
|
|
|
const confirmed = confirm(
|
|
|
|
|
|
`Slet "${project.name}" med alle dets opgaver?\n\n` +
|
|
|
|
|
|
`Det havner i papirkurven og kan hentes tilbage under ` +
|
|
|
|
|
|
`Indstillinger → Data og skabeloner i ${days} dage.`
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!confirmed) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteProject(project.id);
|
|
|
|
|
|
await app.refreshProjects();
|
|
|
|
|
|
goto('/');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 22:04:55 +02:00
|
|
|
|
// --- dropping a task into a section ---------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The section under the pointer, '' for the unsectioned block at the top.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Only the task *rows* used to accept a drop, so a section with nothing in it
|
|
|
|
|
|
* had nothing to aim at and could not be filled by dragging at all — and a
|
|
|
|
|
|
* section you have just made is always empty. The board has always taken a drop
|
|
|
|
|
|
* on the column; this is the same idea in the list.
|
|
|
|
|
|
*/
|
|
|
|
|
|
let overSection = $state(null);
|
|
|
|
|
|
|
|
|
|
|
|
function onSectionDragOver(event, sectionId) {
|
|
|
|
|
|
if (!canEdit || !carries(event, TASK)) return;
|
|
|
|
|
|
accept(event);
|
|
|
|
|
|
overSection = sectionId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onSectionDrop(event, sectionId) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
const id = dragged(event, TASK);
|
|
|
|
|
|
overSection = null;
|
|
|
|
|
|
if (!id || !canEdit) return;
|
|
|
|
|
|
|
|
|
|
|
|
const task = app.get(id);
|
|
|
|
|
|
if (!task || (task.section_id ?? '') === sectionId) return;
|
|
|
|
|
|
|
|
|
|
|
|
// At the end of the section: dropping on the area rather than between two
|
|
|
|
|
|
// rows is the coarse gesture, and landing at the top would push whatever is
|
|
|
|
|
|
// there down.
|
|
|
|
|
|
const existing = open.filter((t) => (t.section_id ?? '') === sectionId && t.id !== id);
|
|
|
|
|
|
const after = [...existing].sort((a, b) => a.sort_order - b.sort_order).at(-1);
|
|
|
|
|
|
|
|
|
|
|
|
const previous = { ...task };
|
|
|
|
|
|
app.replace(id, { ...task, section_id: sectionId });
|
|
|
|
|
|
try {
|
|
|
|
|
|
app.replace(
|
|
|
|
|
|
id,
|
|
|
|
|
|
await api.moveTask(id, {
|
|
|
|
|
|
project_id: project.id,
|
|
|
|
|
|
section_id: sectionId,
|
|
|
|
|
|
after_id: after?.id ?? ''
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
app.replace(id, previous);
|
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 16:58:35 +02:00
|
|
|
|
// --- sections -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
let addingSection = $state(false);
|
|
|
|
|
|
let renamingSection = $state(null);
|
|
|
|
|
|
let sectionName = $state('');
|
|
|
|
|
|
|
|
|
|
|
|
async function addSection(event) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
const name = sectionName.trim();
|
|
|
|
|
|
if (!name) return;
|
|
|
|
|
|
addingSection = false;
|
|
|
|
|
|
sectionName = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
sections = [...sections, await api.createSection(project.id, name)];
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function renameSection(event, section) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
const name = sectionName.trim();
|
|
|
|
|
|
renamingSection = null;
|
|
|
|
|
|
if (!name || name === section.name) return;
|
|
|
|
|
|
|
|
|
|
|
|
const previous = sections;
|
|
|
|
|
|
sections = sections.map((s) => (s.id === section.id ? { ...s, name } : s));
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.updateSection(section.id, { name });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
sections = previous;
|
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeSection(section) {
|
|
|
|
|
|
const count = open.filter((t) => t.section_id === section.id).length;
|
|
|
|
|
|
const warning = count
|
|
|
|
|
|
? `Slet sektionen "${section.name}"? De ${count} opgaver bliver, men mister deres sektion.`
|
|
|
|
|
|
: `Slet sektionen "${section.name}"?`;
|
|
|
|
|
|
if (!confirm(warning)) return;
|
|
|
|
|
|
|
|
|
|
|
|
const previous = sections;
|
|
|
|
|
|
sections = sections.filter((s) => s.id !== section.id);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteSection(section.id);
|
|
|
|
|
|
// The tasks are not deleted with it; they come back unsectioned, and the
|
|
|
|
|
|
// list has to be re-read to show them in the right place.
|
|
|
|
|
|
await app.loadTasks({ project_id: project.id });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
sections = previous;
|
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 22:14:33 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* What each standing is called in the interface.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The API's words are `owner`, `editor` and `viewer`, and they were being
|
|
|
|
|
|
* printed straight into a Danish page. These are the same three words the
|
|
|
|
|
|
* invite form already uses, so the list and the form agree.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const ROLES = { owner: 'Ejer', editor: 'Kan redigere', viewer: 'Kan kun se' };
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Changes what somebody may do, without removing them.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Removing and re-inviting was the only way to correct a role, and it
|
|
|
|
|
|
* unassigns every task they were responsible for on the way past — so fixing a
|
|
|
|
|
|
* dropdown cost somebody their work.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function setMemberRole(member, role) {
|
|
|
|
|
|
const previous = members;
|
|
|
|
|
|
members = members.map((m) => (m.user_id === member.user_id ? { ...m, role } : m));
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.setMemberRole(project.id, member.user_id, role);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
members = previous;
|
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
async function invite(event) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
inviteError = '';
|
|
|
|
|
|
inviteLink = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await api.invite(project.id, inviteEmail, inviteRole);
|
|
|
|
|
|
inviteEmail = '';
|
|
|
|
|
|
if (result.link && !result.emailed) {
|
|
|
|
|
|
// No mail server, or delivery failed. The link is the only way this
|
|
|
|
|
|
// invite reaches anybody, so it is shown rather than swallowed.
|
|
|
|
|
|
inviteLink = result.link;
|
|
|
|
|
|
}
|
|
|
|
|
|
members = (await api.listMembers(project.id)).members;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
inviteError = humanMessage(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="view">
|
|
|
|
|
|
{#if project}
|
|
|
|
|
|
<header>
|
2026-08-17 15:07:15 +02:00
|
|
|
|
<!-- A heading first, a field second. Swapping the h1 out for an input
|
|
|
|
|
|
outright cost the page its heading — which the sidebar smoke test
|
|
|
|
|
|
caught, and which is a real loss: every view here has one, and it is
|
|
|
|
|
|
what a screen reader announces on arrival. So the heading stays and
|
|
|
|
|
|
becomes editable on click. -->
|
|
|
|
|
|
{#if editing}
|
2026-08-17 14:59:51 +02:00
|
|
|
|
<input
|
|
|
|
|
|
class="title"
|
2026-08-18 09:04:12 +02:00
|
|
|
|
use:focusOnMount
|
2026-08-17 14:59:51 +02:00
|
|
|
|
value={project.name}
|
|
|
|
|
|
aria-label="Projektets navn"
|
|
|
|
|
|
onblur={(e) => rename(e.currentTarget)}
|
|
|
|
|
|
onkeydown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
|
e.currentTarget.value = project.name;
|
2026-08-17 15:07:15 +02:00
|
|
|
|
editing = false;
|
2026-08-17 14:59:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{:else}
|
2026-08-17 15:07:15 +02:00
|
|
|
|
<h1 class:renameable={isOwner}>
|
|
|
|
|
|
{#if isOwner}
|
|
|
|
|
|
<button onclick={() => (editing = true)} title="Klik for at omdøbe">
|
|
|
|
|
|
{project.name}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
{project.name}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</h1>
|
2026-08-17 14:59:51 +02:00
|
|
|
|
{/if}
|
2026-08-17 21:50:20 +02:00
|
|
|
|
{#if isOwner && !project.is_inbox}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="color"
|
|
|
|
|
|
style="background: {colorVar(project.color)}"
|
|
|
|
|
|
onclick={() => (choosingColor = !choosingColor)}
|
|
|
|
|
|
aria-expanded={choosingColor}
|
|
|
|
|
|
aria-label="Farve på projektet"
|
|
|
|
|
|
title="Farve på projektet"
|
|
|
|
|
|
></button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-08-17 07:10:51 +02:00
|
|
|
|
<div class="views" role="group" aria-label="Visning">
|
|
|
|
|
|
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class:active={mode === value}
|
|
|
|
|
|
onclick={() => setView(value)}
|
|
|
|
|
|
aria-pressed={mode === value}>{label}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
{#if !project.is_inbox}
|
|
|
|
|
|
<button class="share" onclick={() => (showShare = !showShare)}>
|
|
|
|
|
|
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
2026-08-17 14:59:51 +02:00
|
|
|
|
|
2026-08-17 22:31:43 +02:00
|
|
|
|
{#if !project.is_inbox}
|
|
|
|
|
|
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
|
|
|
|
|
|
Historik
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-08-17 14:59:51 +02:00
|
|
|
|
{#if isOwner && !project.is_inbox}
|
|
|
|
|
|
<button class="remove" onclick={remove} aria-label="Slet projektet">
|
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
|
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
2026-08-17 00:03:20 +02:00
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-08-17 22:31:43 +02:00
|
|
|
|
{#if showLog}
|
|
|
|
|
|
<div class="panel">
|
|
|
|
|
|
<ul class="log">
|
|
|
|
|
|
{#each activity as entry (entry.id)}
|
|
|
|
|
|
<li>
|
2026-08-18 09:04:12 +02:00
|
|
|
|
<!-- Empty when the account has been deleted: the record of what was
|
|
|
|
|
|
done outlives whoever did it, so the row stays and the name goes. -->
|
|
|
|
|
|
<span class="who" class:gone={!entry.user_name}
|
|
|
|
|
|
>{entry.user_name || 'En slettet konto'}</span
|
|
|
|
|
|
>
|
2026-08-17 22:31:43 +02:00
|
|
|
|
<span class="what">
|
2026-08-18 09:04:12 +02:00
|
|
|
|
{eventName(entry.event)}{#if eventDetail(entry)}
|
|
|
|
|
|
<span class="detail">{eventDetail(entry)}</span>{/if}
|
2026-08-17 22:31:43 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
<span class="when">{when(entry.created_at)}</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
{#if !activity.length}
|
|
|
|
|
|
<p class="empty">Ingenting er sket her endnu.</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-08-17 21:50:20 +02:00
|
|
|
|
{#if choosingColor}
|
|
|
|
|
|
<div class="panel swatches" role="group" aria-label="Vælg farve">
|
|
|
|
|
|
{#each COLORS as color (color.id)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="swatch"
|
|
|
|
|
|
class:chosen={(project.color ?? 'graphite') === color.id}
|
|
|
|
|
|
style="background: {colorVar(color.id)}"
|
|
|
|
|
|
title={color.name}
|
|
|
|
|
|
aria-label={color.name}
|
|
|
|
|
|
aria-pressed={(project.color ?? 'graphite') === color.id}
|
|
|
|
|
|
onclick={() => setColor(color.id)}
|
|
|
|
|
|
></button>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
{#if showShare}
|
|
|
|
|
|
<div class="panel">
|
|
|
|
|
|
<ul class="members">
|
|
|
|
|
|
{#each members as member (member.user_id)}
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<span class="avatar" style="background: {member.avatar_color}">
|
|
|
|
|
|
{member.name[0]?.toUpperCase()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="member-name">{member.name}</span>
|
2026-08-17 22:14:33 +02:00
|
|
|
|
<!-- The owner's standing is not a choice: ownership is transferred,
|
|
|
|
|
|
not granted, and the server refuses it. A disabled dropdown
|
|
|
|
|
|
would be a control that exists to say no. -->
|
|
|
|
|
|
{#if isOwner && member.role !== 'owner'}
|
|
|
|
|
|
<select
|
|
|
|
|
|
class="role-picker"
|
|
|
|
|
|
value={member.role}
|
|
|
|
|
|
aria-label="Rolle for {member.name}"
|
|
|
|
|
|
onchange={(e) => setMemberRole(member, e.currentTarget.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="editor">{ROLES.editor}</option>
|
|
|
|
|
|
<option value="viewer">{ROLES.viewer}</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<span class="role">{ROLES[member.role] ?? member.role}</span>
|
|
|
|
|
|
{/if}
|
2026-08-17 00:03:20 +02:00
|
|
|
|
{#if isOwner && member.role !== 'owner'}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="remove"
|
|
|
|
|
|
onclick={async () => {
|
|
|
|
|
|
await api.removeMember(project.id, member.user_id);
|
|
|
|
|
|
members = (await api.listMembers(project.id)).members;
|
|
|
|
|
|
}}
|
|
|
|
|
|
aria-label="Fjern {member.name}">×</button
|
|
|
|
|
|
>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
{#if isOwner}
|
|
|
|
|
|
<form onsubmit={invite}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={inviteEmail}
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
placeholder="e-mailadresse"
|
|
|
|
|
|
aria-label="Inviter via e-mail"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
<select bind:value={inviteRole} aria-label="Rolle">
|
|
|
|
|
|
<option value="editor">Kan redigere</option>
|
|
|
|
|
|
<option value="viewer">Kan kun se</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button type="submit">Inviter</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
{#if inviteLink}
|
|
|
|
|
|
<p class="link-out">
|
|
|
|
|
|
Ingen mailserver sat op — send selv linket:
|
|
|
|
|
|
<code>{inviteLink}</code>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if inviteError}
|
|
|
|
|
|
<p class="error">{inviteError}</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
{#if canEdit}
|
|
|
|
|
|
<QuickAdd projectId={project.id} />
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-08-17 07:10:51 +02:00
|
|
|
|
{#if mode === 'board'}
|
2026-08-18 09:04:12 +02:00
|
|
|
|
<BoardView
|
|
|
|
|
|
{project}
|
|
|
|
|
|
{sections}
|
|
|
|
|
|
{canEdit}
|
|
|
|
|
|
onsectionadded={(section) => (sections = [...sections, section])}
|
|
|
|
|
|
/>
|
2026-08-17 07:10:51 +02:00
|
|
|
|
{:else if mode === 'calendar'}
|
2026-08-17 19:25:10 +02:00
|
|
|
|
<CalendarView projectId={project.id} />
|
2026-08-17 07:10:51 +02:00
|
|
|
|
{:else}
|
2026-08-17 22:04:55 +02:00
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
|
|
|
|
<section
|
|
|
|
|
|
class:over={overSection === ''}
|
|
|
|
|
|
ondragover={(e) => onSectionDragOver(e, '')}
|
|
|
|
|
|
ondragleave={() => (overSection = null)}
|
|
|
|
|
|
ondrop={(e) => onSectionDrop(e, '')}
|
|
|
|
|
|
>
|
2026-08-17 10:40:05 +02:00
|
|
|
|
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
|
2026-08-17 23:09:08 +02:00
|
|
|
|
<!-- Only once there are sections to be "without": on a project with none,
|
|
|
|
|
|
the label is a heading for the only list there is, and the empty
|
|
|
|
|
|
state below already says the project is empty. -->
|
|
|
|
|
|
{#if sections.length && !unsectioned.length}
|
2026-08-17 22:04:55 +02:00
|
|
|
|
<p class="empty">Uden sektion</p>
|
|
|
|
|
|
{/if}
|
2026-08-17 00:03:20 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-08-17 07:10:51 +02:00
|
|
|
|
{#each sections as section (section.id)}
|
|
|
|
|
|
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
2026-08-17 22:04:55 +02:00
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
|
|
|
|
<section
|
|
|
|
|
|
class:over={overSection === section.id}
|
|
|
|
|
|
ondragover={(e) => onSectionDragOver(e, section.id)}
|
|
|
|
|
|
ondragleave={() => (overSection = null)}
|
|
|
|
|
|
ondrop={(e) => onSectionDrop(e, section.id)}
|
|
|
|
|
|
>
|
2026-08-17 16:58:35 +02:00
|
|
|
|
<div class="section-head">
|
|
|
|
|
|
{#if renamingSection === section.id}
|
|
|
|
|
|
<form onsubmit={(e) => renameSection(e, section)}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={sectionName}
|
2026-08-18 09:04:12 +02:00
|
|
|
|
use:focusOnMount
|
2026-08-17 16:58:35 +02:00
|
|
|
|
aria-label="Sektionens navn"
|
|
|
|
|
|
onblur={() => (renamingSection = null)}
|
|
|
|
|
|
onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<h2>{section.name}</h2>
|
|
|
|
|
|
{#if canEdit}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="section-action"
|
|
|
|
|
|
onclick={() => {
|
|
|
|
|
|
renamingSection = section.id;
|
|
|
|
|
|
sectionName = section.name;
|
|
|
|
|
|
}}>Omdøb</button
|
|
|
|
|
|
>
|
|
|
|
|
|
<button class="section-action remove" onclick={() => removeSection(section)}>
|
|
|
|
|
|
Slet
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
2026-08-17 10:40:05 +02:00
|
|
|
|
<TaskList {tasks} projectId={project.id} sectionId={section.id} {canEdit} />
|
2026-08-17 07:10:51 +02:00
|
|
|
|
{#if !tasks.length}
|
|
|
|
|
|
<p class="empty">Tom</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
|
2026-08-17 16:58:35 +02:00
|
|
|
|
{#if canEdit}
|
|
|
|
|
|
<section class="add-section">
|
|
|
|
|
|
{#if addingSection}
|
|
|
|
|
|
<form onsubmit={addSection}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={sectionName}
|
2026-08-18 09:04:12 +02:00
|
|
|
|
use:focusOnMount
|
2026-08-17 16:58:35 +02:00
|
|
|
|
placeholder="Sektionens navn"
|
|
|
|
|
|
aria-label="Ny sektion"
|
|
|
|
|
|
onblur={() => !sectionName.trim() && (addingSection = false)}
|
|
|
|
|
|
onkeydown={(e) => e.key === 'Escape' && (addingSection = false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="add"
|
|
|
|
|
|
onclick={() => {
|
|
|
|
|
|
addingSection = true;
|
|
|
|
|
|
sectionName = '';
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
+ Tilføj sektion
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
{#if !open.length && !sections.length}
|
2026-08-17 07:10:51 +02:00
|
|
|
|
<p class="clear">
|
|
|
|
|
|
<span class="rune" aria-hidden="true">ᚹ</span>
|
|
|
|
|
|
Ingenting her endnu.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{/if}
|
2026-08-17 00:03:20 +02:00
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
{#if project.role === 'viewer'}
|
|
|
|
|
|
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
|
|
|
|
|
|
{/if}
|
2026-08-17 22:04:55 +02:00
|
|
|
|
{:else if status === 'loading'}
|
|
|
|
|
|
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
|
|
|
|
|
|
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
|
|
|
|
|
|
<div class="booting"></div>
|
|
|
|
|
|
{:else if status === 'denied'}
|
2026-08-17 00:03:20 +02:00
|
|
|
|
<p class="clear">Projektet findes ikke, eller du har ikke adgang til det.</p>
|
2026-08-17 22:04:55 +02:00
|
|
|
|
{:else}
|
|
|
|
|
|
<p class="clear">
|
|
|
|
|
|
<span class="rune" aria-hidden="true">ᚹ</span>
|
|
|
|
|
|
Kunne ikke hente projektet. Serveren svarede ikke.
|
|
|
|
|
|
<button class="retry" onclick={() => load(id)}>Prøv igen</button>
|
|
|
|
|
|
</p>
|
2026-08-17 00:03:20 +02:00
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
.view {
|
|
|
|
|
|
max-width: var(--content-max);
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
padding: var(--s6) var(--s4) var(--s8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 07:10:51 +02:00
|
|
|
|
/* A board and a month grid need the room; a list is easier to read narrow. */
|
|
|
|
|
|
.view:has(.board),
|
|
|
|
|
|
.view:has(.calendar) {
|
|
|
|
|
|
max-width: 1400px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 23:09:08 +02:00
|
|
|
|
/* Wraps, and the title keeps a floor.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Without both, a phone squeezes the heading to its min-content — which with
|
|
|
|
|
|
* `overflow-wrap: anywhere` is *one character*, so "Skovvænget" came out as a
|
|
|
|
|
|
* vertical column of letters while the buttons ran off the right edge. Four
|
|
|
|
|
|
* controls beside a title is more than 390px has, so they belong on the next
|
|
|
|
|
|
* line rather than in a race for the same one.
|
|
|
|
|
|
*/
|
2026-08-17 00:03:20 +02:00
|
|
|
|
header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-08-17 23:09:08 +02:00
|
|
|
|
gap: var(--s3);
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-08-17 00:03:20 +02:00
|
|
|
|
margin-bottom: var(--s5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h1 {
|
|
|
|
|
|
font-size: var(--text-2xl);
|
2026-08-17 23:09:08 +02:00
|
|
|
|
/* 8ch rather than 0: the basis is what stops flex shrinking it past the
|
|
|
|
|
|
point where the wrapping below is the better answer. */
|
|
|
|
|
|
flex: 1 1 8ch;
|
|
|
|
|
|
min-width: 8ch;
|
2026-08-17 00:03:20 +02:00
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 15:07:15 +02:00
|
|
|
|
/* The heading's button carries no button-ness: it is the heading, and the only
|
|
|
|
|
|
hint that it does anything is the underline on hover. */
|
|
|
|
|
|
h1.renameable button {
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
letter-spacing: inherit;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
border-bottom: 1px solid transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h1.renameable button:hover {
|
|
|
|
|
|
border-bottom-color: var(--line-strong);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Matches the heading it stands in for, so the swap does not move the page. */
|
2026-08-17 14:59:51 +02:00
|
|
|
|
.title {
|
2026-08-17 23:09:08 +02:00
|
|
|
|
flex: 1 1 8ch;
|
|
|
|
|
|
min-width: 8ch;
|
2026-08-17 14:59:51 +02:00
|
|
|
|
font-size: var(--text-2xl);
|
|
|
|
|
|
font-weight: 560;
|
|
|
|
|
|
letter-spacing: -0.015em;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
padding: var(--s1) var(--s2);
|
|
|
|
|
|
margin-left: calc(var(--s2) * -1);
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title:hover {
|
|
|
|
|
|
border-color: var(--line);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title:focus {
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.remove {
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
width: 30px;
|
|
|
|
|
|
height: 30px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
transition:
|
|
|
|
|
|
color var(--fast) var(--ease),
|
|
|
|
|
|
background var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.remove:hover {
|
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
|
background: var(--danger-sunken);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.remove svg {
|
|
|
|
|
|
width: 16px;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
fill: none;
|
|
|
|
|
|
stroke: currentColor;
|
|
|
|
|
|
stroke-width: 1.6;
|
|
|
|
|
|
stroke-linecap: round;
|
|
|
|
|
|
stroke-linejoin: round;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 21:50:20 +02:00
|
|
|
|
/* A circle in the project's own colour, which is both the control and its own
|
|
|
|
|
|
preview: the thing you are about to change is what you press. */
|
|
|
|
|
|
.color {
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
width: 20px;
|
|
|
|
|
|
height: 20px;
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
box-shadow: 0 0 0 2px var(--ground);
|
|
|
|
|
|
transition: box-shadow var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.color:hover,
|
|
|
|
|
|
.color:focus-visible {
|
|
|
|
|
|
box-shadow:
|
|
|
|
|
|
0 0 0 2px var(--ground),
|
|
|
|
|
|
0 0 0 3px var(--line-strong);
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.swatches {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.swatch {
|
|
|
|
|
|
width: 22px;
|
|
|
|
|
|
height: 22px;
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
/* A ring in the panel's own colour, so the selected swatch reads as chosen
|
|
|
|
|
|
without changing size and shuffling the row as you move along it. */
|
|
|
|
|
|
box-shadow: 0 0 0 2px var(--surface);
|
|
|
|
|
|
transition: box-shadow var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.swatch:hover,
|
|
|
|
|
|
.swatch:focus-visible {
|
|
|
|
|
|
box-shadow:
|
|
|
|
|
|
0 0 0 2px var(--surface),
|
|
|
|
|
|
0 0 0 3px var(--line-strong);
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.swatch.chosen {
|
|
|
|
|
|
box-shadow:
|
|
|
|
|
|
0 0 0 2px var(--surface),
|
|
|
|
|
|
0 0 0 3px var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 07:10:51 +02:00
|
|
|
|
.views {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 1px;
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
background: var(--line);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.views button {
|
|
|
|
|
|
padding: var(--s1) var(--s3);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
background: var(--ground);
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
transition:
|
|
|
|
|
|
background var(--fast) var(--ease),
|
|
|
|
|
|
color var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.views button:hover {
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.views button.active {
|
|
|
|
|
|
background: var(--surface-raised);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
.share {
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
padding: var(--s1) var(--s3);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
transition: border-color var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share:hover {
|
|
|
|
|
|
border-color: var(--line-strong);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel {
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
padding: var(--s4);
|
|
|
|
|
|
margin-bottom: var(--s5);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.members {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.members li {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.member-name {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role {
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 22:14:33 +02:00
|
|
|
|
.role-picker {
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
padding: var(--s1) var(--s2);
|
|
|
|
|
|
background: var(--surface-raised);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
transition: border-color var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-picker:hover,
|
|
|
|
|
|
.role-picker:focus {
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
.remove {
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
width: 20px;
|
|
|
|
|
|
height: 20px;
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.remove:hover {
|
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
|
background: var(--danger-sunken);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
form {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
form input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 160px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
form input,
|
|
|
|
|
|
form select {
|
|
|
|
|
|
padding: var(--s2) var(--s3);
|
|
|
|
|
|
background: var(--surface-raised);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
form input:focus,
|
|
|
|
|
|
form select:focus {
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
form button {
|
|
|
|
|
|
padding: var(--s2) var(--s4);
|
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
|
color: var(--accent-ink);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
font-weight: 550;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 22:31:43 +02:00
|
|
|
|
.log {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
/* Capped and scrolled: a busy project's log is thousands of lines, and a
|
|
|
|
|
|
panel that pushes the tasks off the screen is a panel you close again. */
|
|
|
|
|
|
max-height: 320px;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log li {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
|
padding: var(--s2) 0;
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log li:last-child {
|
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.who {
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 09:04:12 +02:00
|
|
|
|
/* A name that is not a name reads at the weight of the surrounding prose, not
|
|
|
|
|
|
at the weight a person's name gets. */
|
|
|
|
|
|
.who.gone {
|
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 22:31:43 +02:00
|
|
|
|
.what {
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail {
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.when {
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
.link-out {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.link-out code {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-top: var(--s1);
|
|
|
|
|
|
padding: var(--s2);
|
|
|
|
|
|
background: var(--surface-sunken);
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
section {
|
|
|
|
|
|
margin-top: var(--s5);
|
2026-08-17 22:04:55 +02:00
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* The whole section lights up, because the whole section is the target. A line
|
|
|
|
|
|
between two rows means "here exactly"; this means "in this one". */
|
|
|
|
|
|
section.over {
|
|
|
|
|
|
box-shadow: 0 0 0 1px var(--accent);
|
|
|
|
|
|
background: var(--surface-sunken);
|
2026-08-17 00:03:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h2 {
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
font-weight: 560;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
padding: 0 var(--s2) var(--s2);
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
margin-bottom: var(--s2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 16:58:35 +02:00
|
|
|
|
.section-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
margin-bottom: var(--s2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-head h2 {
|
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Hidden until the section is hovered or something in it has focus: a heading
|
|
|
|
|
|
with two buttons permanently beside it stops reading as a heading. */
|
|
|
|
|
|
.section-action {
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
padding: 0 var(--s1);
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transition: opacity var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-head:hover .section-action,
|
|
|
|
|
|
.section-action:focus-visible {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-action:hover {
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-action.remove:hover {
|
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-head form {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-head input,
|
|
|
|
|
|
.add-section input {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: var(--s1) var(--s2);
|
|
|
|
|
|
background: var(--surface-raised);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-head input:focus,
|
|
|
|
|
|
.add-section input:focus {
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.add-section {
|
|
|
|
|
|
margin-top: var(--s5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.add-section .add {
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
padding: var(--s2);
|
|
|
|
|
|
transition: color var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.add-section .add:hover {
|
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
|
.empty,
|
|
|
|
|
|
.readonly {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: var(--s2);
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.readonly {
|
|
|
|
|
|
margin-top: var(--s5);
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.clear {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
|
padding: var(--s8) var(--s4);
|
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.rune {
|
|
|
|
|
|
font-size: var(--text-2xl);
|
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
}
|
2026-08-17 22:04:55 +02:00
|
|
|
|
|
|
|
|
|
|
.booting {
|
|
|
|
|
|
min-height: 50vh;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.retry {
|
|
|
|
|
|
padding: var(--s2) var(--s4);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
|
transition:
|
|
|
|
|
|
border-color var(--fast) var(--ease),
|
|
|
|
|
|
color var(--fast) var(--ease);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.retry:hover {
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
2026-08-17 00:03:20 +02:00
|
|
|
|
</style>
|