feat(notes): en flade at skrive i, og noten dukker op i projektet
Release / Build and publish image (push) Failing after 9m55s
Release / Build and publish image (push) Failing after 9m55s
Tredje etape. En liste til venstre, noten til højre — ikke et gitter af kort: en note læses langt oftere på sin første linje end i sin helhed, og en liste viser tyve af dem, hvor et gitter viser seks. Gemmes når man holder op med at skrive. En note er det ene sted i programmet, hvor en times skrivning kan gå tabt på ét fejltryk, og en Gem-knap er en knap, der findes for at blive glemt. Teksten holdes adskilt fra den note, den kom fra, så en gemning, der lander sent, ikke kan overskrive det, der er skrevet siden. Og udbetalingen: skriver man #GarageRisteriet i en note, står den på projektets side. Ingen har filet den derhen — den dukker op, fordi nogen nævnte projektet mens de skrev. Slået op på navnet og ikke på et id, for et #tag skrives i hånden, og et menneske skriver navnet. Markdown i et almindeligt tekstfelt indtil videre. Live preview, hvor mærkerne kun vises i den linje markøren står i, er næste stykke og det dyre; det her er den form, den skal sidde inde i, og alt andet er allerede sandt uden den. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fbfc98e217
commit
a2a39c4e17
@@ -1900,3 +1900,54 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en note kan skrives, findes igen, og peger på det den nævner', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Notetesteriet');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Notetesteriet' })).toBeVisible();
|
||||||
|
|
||||||
|
await sidebar.getByRole('link', { name: 'Noter' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Noter' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const body = page.getByLabel('Notens tekst');
|
||||||
|
await body.fill('Møde om #Notetesteriet\n\nHan vil gerne have kaffe hver uge.');
|
||||||
|
// Saved on the way out, so there is no Gem-knap to forget.
|
||||||
|
await body.blur();
|
||||||
|
|
||||||
|
// The title comes from the first line, the way Apple Notes does it. Without it
|
||||||
|
// a list of notes is a list of "Uden titel".
|
||||||
|
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
|
||||||
|
|
||||||
|
// And the #tag was read out of the text, unasked. This is the whole point: it
|
||||||
|
// is the same tag a task carries, so the note can be found from the project.
|
||||||
|
// Scoped to the panel under the editor: the same text is also in the textarea
|
||||||
|
// and in the list preview, which is three of the same thing and none of them
|
||||||
|
// the one being asserted.
|
||||||
|
await expect(page.locator('.link', { hasText: '#Notetesteriet' })).toBeVisible();
|
||||||
|
|
||||||
|
// Findable by a word from the body, and in the other Danish spelling too.
|
||||||
|
await page.getByLabel('Søg i noter').fill('møde');
|
||||||
|
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
|
||||||
|
await page.getByLabel('Søg i noter').fill('mode');
|
||||||
|
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
|
||||||
|
|
||||||
|
// It survives a reload, which is the only proof that it was ever written down.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
|
||||||
|
|
||||||
|
// And the payoff: the note turns up in the project it named, without anybody
|
||||||
|
// filing it there. This is what a #tag being the same tag actually buys.
|
||||||
|
await sidebar.getByRole('link', { name: 'Notetesteriet' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Notetesteriet' })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('.notes').getByRole('link', { name: /Møde om/ })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -360,6 +360,18 @@ export const api = {
|
|||||||
authorizeGmail: () => post('/gmail/authorize'),
|
authorizeGmail: () => post('/gmail/authorize'),
|
||||||
syncGmail: () => post('/gmail/sync'),
|
syncGmail: () => post('/gmail/sync'),
|
||||||
|
|
||||||
|
// Notes. Listing and searching are one endpoint because they answer the same
|
||||||
|
// question with different words.
|
||||||
|
notes: (params = {}) => {
|
||||||
|
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
|
||||||
|
return get('/notes' + (q ? `?${q}` : ''));
|
||||||
|
},
|
||||||
|
note: (id) => get(`/notes/${id}`),
|
||||||
|
createNote: (body) => post('/notes', body),
|
||||||
|
updateNote: (id, body) => patch(`/notes/${id}`, body),
|
||||||
|
deleteNote: (id) => del(`/notes/${id}`),
|
||||||
|
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
|
||||||
|
|
||||||
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
||||||
// there is no instance-wide registration behind these the way Gmail has one.
|
// there is no instance-wide registration behind these the way Gmail has one.
|
||||||
mailboxes: () => get('/mailboxes'),
|
mailboxes: () => get('/mailboxes'),
|
||||||
|
|||||||
@@ -379,6 +379,16 @@
|
|||||||
</a>
|
</a>
|
||||||
{#if app.inbox}
|
{#if app.inbox}
|
||||||
{@render projectRow(app.inbox, false)}
|
{@render projectRow(app.inbox, false)}
|
||||||
|
<!-- Notes sit with the fixed places rather than among the projects: it is a
|
||||||
|
|
||||||
|
place you go, not a thing filed under one. -->
|
||||||
|
<a href="/noter" class="fixed" onclick={onnavigate}>
|
||||||
|
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
{t('notes.title')}
|
||||||
|
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,19 @@
|
|||||||
*/
|
*/
|
||||||
export const da = {
|
export const da = {
|
||||||
// --- the shell ---------------------------------------------------------------
|
// --- the shell ---------------------------------------------------------------
|
||||||
|
'notes.title': 'Noter',
|
||||||
|
'notes.new': 'Ny note',
|
||||||
|
'notes.search': 'Søg i noter',
|
||||||
|
'notes.body': 'Notens tekst',
|
||||||
|
'notes.placeholder': 'Skriv. #projekt og [[en anden note]] bliver til henvisninger.',
|
||||||
|
'notes.untitled': 'Uden titel',
|
||||||
|
'notes.noneYet': 'Ingen noter endnu.',
|
||||||
|
'notes.noneFound': 'Ingen noter matcher.',
|
||||||
|
'notes.pickOne': 'Vælg en note, eller lav en ny.',
|
||||||
|
'notes.saving': 'Gemmer …',
|
||||||
|
'notes.saved': 'Gemt',
|
||||||
|
'notes.delete': 'Slet',
|
||||||
|
'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
|
||||||
'nav.today': 'I dag',
|
'nav.today': 'I dag',
|
||||||
'nav.upcoming': 'Kommende',
|
'nav.upcoming': 'Kommende',
|
||||||
'nav.delegated': 'Venter på andre',
|
'nav.delegated': 'Venter på andre',
|
||||||
|
|||||||
@@ -12,6 +12,19 @@
|
|||||||
*/
|
*/
|
||||||
export const en = {
|
export const en = {
|
||||||
// --- the shell ---------------------------------------------------------------
|
// --- the shell ---------------------------------------------------------------
|
||||||
|
'notes.title': 'Notes',
|
||||||
|
'notes.new': 'New note',
|
||||||
|
'notes.search': 'Search notes',
|
||||||
|
'notes.body': "The note's text",
|
||||||
|
'notes.placeholder': 'Write. #project and [[another note]] become references.',
|
||||||
|
'notes.untitled': 'Untitled',
|
||||||
|
'notes.noneYet': 'No notes yet.',
|
||||||
|
'notes.noneFound': 'No notes match.',
|
||||||
|
'notes.pickOne': 'Pick a note, or write a new one.',
|
||||||
|
'notes.saving': 'Saving …',
|
||||||
|
'notes.saved': 'Saved',
|
||||||
|
'notes.delete': 'Delete',
|
||||||
|
'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.',
|
||||||
'nav.today': 'Today',
|
'nav.today': 'Today',
|
||||||
'nav.upcoming': 'Upcoming',
|
'nav.upcoming': 'Upcoming',
|
||||||
'nav.delegated': 'Waiting on others',
|
'nav.delegated': 'Waiting on others',
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Notes.
|
||||||
|
*
|
||||||
|
* A list on the left, the note itself on the right. Not a grid of cards: a note
|
||||||
|
* is read by its first line far more often than by its whole self, and a list
|
||||||
|
* shows twenty of those where a grid shows six.
|
||||||
|
*
|
||||||
|
* Markdown in a plain textarea for now. The live preview — where the marks show
|
||||||
|
* only in the line the cursor is in — is the next piece and the expensive one;
|
||||||
|
* this is the shape it will sit inside, and everything else is already true
|
||||||
|
* without it.
|
||||||
|
*/
|
||||||
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
|
let notes = $state([]);
|
||||||
|
let selected = $state(null);
|
||||||
|
let query = $state('');
|
||||||
|
let status = $state('loading');
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
// The text being edited, kept apart from the note it came from so a save that
|
||||||
|
// arrives late cannot overwrite what has been typed since.
|
||||||
|
let draft = $state('');
|
||||||
|
let timer;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
load(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(q) {
|
||||||
|
try {
|
||||||
|
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
||||||
|
status = 'ready';
|
||||||
|
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
status = 'failed';
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(note) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
selected = note;
|
||||||
|
draft = note?.body ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
try {
|
||||||
|
const note = await api.createNote({ body: '' });
|
||||||
|
notes = [note, ...notes];
|
||||||
|
open(note);
|
||||||
|
document.querySelector('.editor textarea')?.focus();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved as you stop typing rather than on a button.
|
||||||
|
*
|
||||||
|
* A note is the one place in this program where an hour of writing can be lost
|
||||||
|
* to one mis-click, and a Gem-knap is a button that exists to be forgotten. The
|
||||||
|
* delay is long enough that a sentence is one save and not twenty.
|
||||||
|
*/
|
||||||
|
function typed() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(save, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!selected) return;
|
||||||
|
const id = selected.id;
|
||||||
|
const body = draft;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const saved = await api.updateNote(id, { body });
|
||||||
|
notes = notes.map((n) => (n.id === id ? saved : n));
|
||||||
|
// Only if it is still the one on screen: switching notes mid-save must not
|
||||||
|
// drag the previous one's title back onto this one.
|
||||||
|
if (selected?.id === id) selected = saved;
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(note) {
|
||||||
|
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
|
||||||
|
try {
|
||||||
|
await api.deleteNote(note.id);
|
||||||
|
notes = notes.filter((n) => n.id !== note.id);
|
||||||
|
if (selected?.id === note.id) open(notes[0] ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the note points at, for the panel under the editor.
|
||||||
|
let links = $derived(selected?.links ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<aside>
|
||||||
|
<div class="head">
|
||||||
|
<h1>{t('notes.title')}</h1>
|
||||||
|
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
type="search"
|
||||||
|
bind:value={query}
|
||||||
|
placeholder={t('notes.search')}
|
||||||
|
aria-label={t('notes.search')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if status === 'loading'}
|
||||||
|
<p class="hint">{t('history.loading')}</p>
|
||||||
|
{:else if !notes.length}
|
||||||
|
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#each notes as note (note.id)}
|
||||||
|
<li>
|
||||||
|
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
|
||||||
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
|
<span class="preview">{note.body.slice(0, 80)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="editor">
|
||||||
|
{#if selected}
|
||||||
|
<textarea
|
||||||
|
bind:value={draft}
|
||||||
|
oninput={typed}
|
||||||
|
onblur={save}
|
||||||
|
spellcheck="true"
|
||||||
|
aria-label={t('notes.body')}
|
||||||
|
placeholder={t('notes.placeholder')}
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
||||||
|
{#if links.length}
|
||||||
|
<span class="links">
|
||||||
|
{#each links as link}
|
||||||
|
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<button class="remove" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||||
|
</footer>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">{t('notes.pickOne')}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.notes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||||
|
gap: var(--s4);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
min-height: 0;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
padding-right: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 0 var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--s2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.on {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row strong {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 560;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 50vh;
|
||||||
|
resize: none;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: var(--s2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding-top: var(--s2);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 var(--s1);
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty,
|
||||||
|
.hint {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.notes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
border-right: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,6 +43,23 @@
|
|||||||
load(id);
|
load(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notes that mention this project. Looked up by name rather than by id, because
|
||||||
|
// a #tag is written by hand and a person types the name — the same string the
|
||||||
|
// quick-add parser reads out of a task.
|
||||||
|
let notes = $state([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const name = project?.name;
|
||||||
|
if (!name) {
|
||||||
|
notes = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.notesLinking('project', name)
|
||||||
|
.then((r) => (notes = r.notes ?? []))
|
||||||
|
.catch(() => (notes = []));
|
||||||
|
});
|
||||||
|
|
||||||
async function load(projectId) {
|
async function load(projectId) {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
status = 'loading';
|
status = 'loading';
|
||||||
@@ -793,6 +810,24 @@
|
|||||||
{#if project.role === 'viewer'}
|
{#if project.role === 'viewer'}
|
||||||
<p class="readonly">{t('project.readOnly')}</p>
|
<p class="readonly">{t('project.readOnly')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- What has been written about this project, which is the payoff for a #tag
|
||||||
|
in a note being the same tag a task carries: nobody files anything here,
|
||||||
|
it turns up because somebody mentioned the project while writing. -->
|
||||||
|
{#if notes.length}
|
||||||
|
<section class="notes">
|
||||||
|
<h2>{t('notes.title')}</h2>
|
||||||
|
<ul>
|
||||||
|
{#each notes as note (note.id)}
|
||||||
|
<li>
|
||||||
|
<a href="/noter?note={note.id}">
|
||||||
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
|
<span>{note.body.slice(0, 90)}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
{:else if status === 'loading'}
|
{:else if status === 'loading'}
|
||||||
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
|
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
|
||||||
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
|
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
|
||||||
@@ -809,6 +844,53 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.notes {
|
||||||
|
margin-top: var(--s4);
|
||||||
|
padding-top: var(--s3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes h2 {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-bottom: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes a {
|
||||||
|
display: block;
|
||||||
|
padding: var(--s2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes a:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes strong {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 560;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes span {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
/* Recessed, because it is a record rather than a plan. The rows keep their own
|
/* Recessed, because it is a record rather than a plan. The rows keep their own
|
||||||
completed styling; this is about the section around them. */
|
completed styling; this is about the section around them. */
|
||||||
.done {
|
.done {
|
||||||
|
|||||||
Reference in New Issue
Block a user