Compare commits

..
2 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 8aad7c13b0 perf(notes): listen sendte hele noter for at vise halvfems tegn af dem
Release / Build and publish image (push) Failing after 9m55s
Chrome frøs på notesiden, og der var to grunde.

**Nyttelasten.** Listen viser en titel og omkring halvfems tegn. Den fik hele
noter for at gøre det: med tolv hundrede blev svaret 3,9 MB, hvoraf otteoghalvfems
procent var brødtekst, ingen ville læse på den skærm — hentet, tolket og holdt i
hukommelsen hver gang siden blev åbnet. Nu 763 KB.

Kroppen tømmes frem for at blive forkortet, og det er den vigtige halvdel. Et felt,
der hedder `body` og indeholder et *stykke* af kroppen, er en fælde: editoren ville
åbne det, pausen ville gemme det, og noten ville være skåret ned til sit eget
uddrag af ingenting andet end at være blevet kigget på. Tom er en form, klienten
kan se efter — og den gør det og henter hele noten, når den åbner en.

Det kostede en adskillelse mere: hvilken række der er *valgt*, og hvilken note der
er *indlæst*, er nu to ting. Editoren indlæser sin tekst, når notens id skifter, så
hvis den fik uddraget først og hele noten bagefter, ville id'et være det samme
begge gange og den anden aldrig blive vist. Man ville sidde med en tom note, der
lige havde haft en titel.

**Tegningen.** Alle tolv hundrede rækker fik layout og maling, også de tusind
ingen kan se: 2,1 sekunder til første tegning. `content-visibility: auto` lader
browseren springe det over og tage det, når man ruller derhen — 0,95 sekunder, og
uden en eneste linje JavaScript, ingen rullematematik og ingen liste, der kan komme
ud af trit med sig selv.

**Og søgefeltet.** Det var en pille med et forstørrelsesglas tegnet ind som et
baggrundsbillede med farven skrevet i hånden — #8b918d, uanset tema — hvilket
gjorde det til det ene sted på siden, der ikke fulgte resten. Nu samme form som
søgefeltet i toppen, et rigtigt ikon i `currentColor`, en ryd-knap og et antal
fundne.

Det havde heller ingen pause: hver tast sendte en søgning gennem tolv hundrede
noter, så "kaffe" var fem forespørgsler, hvoraf de fire var forældede inden de kom
tilbage — og svarene kunne lande i den forkerte rækkefølge. Nu én.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 10:36:35 +02:00
Kristian WindandClaude Opus 5 3a02bc8304 fix(notes): søgningen svarede med det forkerte først, og et træk gjorde ingenting
Release / Build and publish image (push) Failing after 9m55s
**Søgningen sorterede efter dato.** Usynligt med ti noter og ubrugeligt med tolv
hundrede: de blev alle importeret samme aften, så `updated_at` siger ingenting, og
loftet skar listen af ved halvtreds vilkårlige rækker. At søge "Tesla" svarede med
en note om nginx først og noten, der hedder "Tesla Model Y", bagefter — hvilket
læses som en søgning, der ikke virker, fordi det fra brugerens plads er præcis
dét.

Nu bm25, med titlen vægtet ti gange kroppen: et ord i titlen er, hvad noten
*handler om*, mens det samme ord én gang i en lang krop er en henvisning. `fold`
vejer som kroppen, ellers ville et dansk match slå et præcist. Sammenføjet frem
for `rowid IN (…)`, fordi rangeringen hører til matchet, og en underforespørgsel
smider den væk. Fladen sorterer ikke om, når der er søgt — det ville kaste netop
det væk, der gør en søgning i tolv hundrede noter brugbar.

**Et træk af en note hen på et projekt gjorde ingenting.** `ondragover` tog imod
både opgaver og noter, så rækken lyste op — og `ondrop` sendte kun opgaver videre,
mens noten faldt ned i grenen, der omarrangerer projekter, hvor den ikke betyder
noget. Den værst mulige form for i stykker: den så ud, som om den ville virke,
helt indtil man slap.

Det står i overleveringen som "virker i koden, men ikke fastholdt i prøver, fordi
prøven blev upålidelig". Den er skrevet igen, og den ser på resultatet — ligger
noten i projektet bagefter — frem for på om rækken blev fremhævet, hvilket var
netop det, den gamle kunne se og som blev ved med at være sandt, mens funktionen
var i stykker.

**Og datoen i listen var altid `updated_at`.** For tolv hundrede importerede noter
er det i aftes, så en note fra 2017 stod som "i går", og den dato, man sorterede
efter, var ingen steder at se. Rækken viser nu den dato, listen er sorteret efter,
og har begge i sit værktøjstip.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 09:58:41 +02:00
10 changed files with 412 additions and 46 deletions
+37 -3
View File
@@ -48,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "search notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
return
}
@@ -62,7 +62,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "notes in project", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
return
}
@@ -74,7 +74,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
}
// briefly replaces each note's body with the first of it.
//
// The list shows a title and about ninety characters. It was being sent whole
// notes to do that: with twelve hundred of them the response was 845 KB, of which
// ninety-eight per cent was body text nobody would read on that screen — fetched,
// parsed and held in memory on every visit.
//
// Body is emptied rather than shortened, and that is the important half. A field
// called `body` that holds a *piece* of the body is a trap: the editor would open
// it, the debounce would save it, and the note would be cut down to its own
// preview by nothing more than being looked at. Empty is a shape the client can
// check, and it does — see the notes page, which fetches the whole note when it
// opens one.
//
// Four hundred characters rather than ninety: the client strips Markdown before
// it shows anything, and a heading marker and a bold word are characters that
// vanish. Cheap insurance against a preview that ends up shorter than the line it
// is meant to fill.
const previewChars = 400
func briefly(notes []store.Note) []store.Note {
out := make([]store.Note, 0, len(notes))
for _, n := range notes {
body := []rune(n.Body)
if len(body) > previewChars {
body = body[:previewChars]
}
n.Preview = string(body)
n.Body = ""
out = append(out, n)
}
return out
}
// readable drops what this person may not see.
+8 -1
View File
@@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
}
// The list carries a preview, not the whole note — see briefly(). The body is
// fetched by id, which is what the interface does when it opens one.
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
notes := listed["notes"].([]any)
if len(notes) != 1 {
t.Fatalf("found %d notes", len(notes))
}
body := notes[0].(map[string]any)["body"].(string)
noteID := notes[0].(map[string]any)["id"].(string)
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
}
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
body := whole["body"].(string)
// The link points at the file where it now lives, not at a path inside an
// archive nobody kept.
+46 -7
View File
@@ -20,6 +20,16 @@ type Note struct {
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// Preview is the first of the body, for a list that shows one line of it.
//
// It exists so a list of twelve hundred notes does not have to carry twelve
// hundred whole notes: the list rendered 845 KB of JSON to show ninety
// characters of each. When it is set, Body is deliberately empty — see
// handleListNotes. That is not tidiness but a guard: an editor handed a
// truncated body would save the truncation, and the note would be cut down to
// its own preview.
Preview string `json:"preview,omitempty"`
// Links is what the body points at, read out of the text on the way in. Never
// set by a caller: it is derived, and a caller that set it would be describing
// a note whose text says something else.
@@ -35,6 +45,14 @@ type NoteLink struct {
const noteColumns = `id, project_id, title, body, created_by, pinned,
created_at, updated_at, deleted_at`
// The same columns, said with the table in front of them.
//
// Needed only by the search, which joins `notes_fts` — and that table carries its
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at`
// Reference syntax, and deliberately the same characters the quick-add parser
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
// into the task box — not a second kind of tag that happens to look alike. That
@@ -238,12 +256,27 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
if expr == "" {
return nil, nil
}
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
// on it would work and read the whole index to do it.
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
ORDER BY updated_at DESC
// Ordered by how well it matched, not by when it was last touched.
//
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
// useless at twelve hundred: every one of them was imported the same evening,
// so `updated_at` says nothing, and the limit then cut the list at fifty
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
// the note actually called "Tesla Model Y" second — which reads as a search
// that does not work, because from where the person is sitting it is one.
//
// bm25 with the title weighted ten times the body. A word in the title is what
// the note is *about*; the same word once in a long body is a mention. `fold`
// carries the same text transliterated, so it must weigh the same as the body
// or a Danish match would outrank an exact one.
//
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
// subquery throws it away.
return db.notesJoin(ctx, noteColumnsQualified, `
JOIN notes_fts ON notes_fts.rowid = notes.rowid
WHERE notes.deleted_at IS NULL
AND notes_fts MATCH ?
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
LIMIT ?`, expr, limit)
}
@@ -295,7 +328,13 @@ func (db *DB) RestoreNote(ctx context.Context, id string) error {
}
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
return db.notesJoin(ctx, noteColumns, where, args...)
}
// notesJoin is notesWhere with the column list spelled out, for the one caller
// that joins another table and must therefore say which `id` it means.
func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) ([]Note, error) {
rows, err := db.QueryContext(ctx, `SELECT `+columns+` FROM notes `+where, args...)
if err != nil {
return nil, err
}
+36
View File
@@ -333,3 +333,39 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
t.Error("a note title did not match itself")
}
}
// En søgning skal svare med det, der passer bedst, øverst.
//
// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og
// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen
// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker.
// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla
// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det
// fra brugerens plads er præcis dét.
func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
// Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun
// nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato.
for _, body := range []string{
"# Tesla Model Y\n\nBilen selv.\n",
"# Ladestandere\n\nNoter om Tesla og andre mærker.\n",
"# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n",
} {
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil {
t.Fatal(err)
}
}
found, err := db.SearchNotes(ctx, "Tesla", 10)
if err != nil {
t.Fatal(err)
}
if len(found) != 3 {
t.Fatalf("fandt %d noter, ventede 3", len(found))
}
if found[0].Title != "Tesla Model Y" {
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
}
}
+54
View File
@@ -2740,3 +2740,57 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
expect(trouble).toEqual([]);
});
test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) => {
const trouble = watchForTrouble(page);
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await page.goto('/');
await sidebar.getByLabel('Nyt projekt').click();
await sidebar.getByLabel('Projektnavn').fill('Trækmål');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Trækmål' })).toBeVisible();
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: '# Løs note\n\nSkal flyttes ved at trække.\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
await page.evaluate((noteId) => (window.__noteId = noteId), id);
const row = page.locator('.notes button.row').filter({ hasText: 'Løs note' }).first();
await expect(row).toBeVisible();
// Selve gesten, med de rigtige hændelser og den nyttelast, appen sender.
// Rækken tog imod dragover og lod slippet falde ned i grenen, der omarrangerer
// projekter — så den lyste op og gjorde ingenting. Prøven skal derfor gå hele
// vejen til slippet og se på resultatet, ikke på at rækken blev fremhævet.
await page.evaluate(() => {
const dt = new DataTransfer();
dt.setData('application/x-verdande-note', window.__noteId);
const target = [...document.querySelectorAll('nav a')].find((a) =>
a.textContent.includes('Trækmål')
);
for (const type of ['dragover', 'drop']) {
target.dispatchEvent(new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true }));
}
});
await page.waitForTimeout(1000);
// Det, der tæller: noten ligger i projektet bagefter.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return await r.json();
}, id);
expect(body.project_id).toBeTruthy();
expect(trouble).toEqual([]);
});
+14 -2
View File
@@ -363,6 +363,15 @@
overProject = project.id;
}
/**
* Et slip på et projekt, uanset hvad der bliver sluppet.
*
* Rækken kaldte den her for opgaver og lod noter falde ned i grenen, der
* omarrangerer projekter — hvor en note ikke betyder noget. Samtidig tog
* `onTaskDragOver` imod begge, så rækken lyste op, når en note blev trukket hen
* over den. Den værst mulige form for i stykker: den så ud, som om den ville
* virke, helt indtil man slap.
*/
async function onTaskDrop(event, project) {
event.preventDefault();
overProject = null;
@@ -569,14 +578,17 @@
ondragstart={(e) => sortable && onDragStart(e, project)}
ondragend={clearDrag}
ondragover={(e) => {
if (carries(e, TASK)) onTaskDragOver(e, project);
if (carries(e, TASK) || carries(e, NOTE)) onTaskDragOver(e, project);
else if (sortable) onDragOver(e, project);
}}
ondragleave={() => {
overId = null;
overProject = null;
}}
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
ondrop={(e) =>
carries(e, TASK) || carries(e, NOTE)
? onTaskDrop(e, project)
: sortable && onDrop(e, project)}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{projectName(project)}
+2
View File
@@ -46,6 +46,8 @@ export const da = {
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
'notes.newHere': 'Ny note',
'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.',
'notes.clearSearch': 'Ryd søgningen',
'notes.found': '{count} noter',
'notes.strike': 'Gennemstreget',
'notes.showSource': 'Vis som kode',
'notes.source': 'Notens kildetekst',
+2
View File
@@ -42,6 +42,8 @@ export const en = {
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
'notes.newHere': 'New note',
'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.',
'notes.clearSearch': 'Clear the search',
'notes.found': '{count} notes',
'notes.strike': 'Strikethrough',
'notes.showSource': 'Show as code',
'notes.source': 'Note source',
+210 -32
View File
@@ -53,7 +53,16 @@
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
let ordered = $derived(
let ordered = $derived.by(() => {
// En søgning har allerede en rækkefølge, og den er bedre end nogen af de
// tre herunder: serveren har sorteret efter hvor godt hver note matchede.
// Sorterede vi den om efter dato, ville vi kaste netop det væk, der gør en
// søgning i tolv hundrede noter brugbar.
if (query.trim()) return notes;
return sortedByChoice();
});
const sortedByChoice = () =>
[...notes].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (order === 'title') {
@@ -61,10 +70,35 @@
}
const field = order === 'created' ? 'created_at' : 'updated_at';
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
})
);
});
let selected = $state(null);
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
let selectedId = $state(null);
let query = $state('');
/**
* Det, der står i feltet, og det, der er søgt efter.
*
* To ting, fordi de ikke er den samme: `draftQuery` opdateres ved hvert
* tastetryk,
* så feltet føles som et felt, og `query` halter en kvart sekund bagefter, fordi
* det er den, der koster en forespørgsel. Uden det sendte "kaffe" fem søgninger
* gennem tolv hundrede noter, hvoraf de fire var forældede, inden de kom
* tilbage — og svarene kunne lande i den forkerte rækkefølge.
*/
let draftQuery = $state('');
let typing = $state(false);
let searchTimer;
function search(next) {
draftQuery = next;
typing = next.trim() !== '' && next !== query;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
query = draftQuery;
typing = false;
}, 250);
}
let status = $state('loading');
let saving = $state(false);
@@ -81,7 +115,7 @@
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
$effect(() => {
if (!asked || selected?.id === asked) return;
if (!asked || selectedId === asked) return;
const found = notes.find((n) => n.id === asked);
if (found) open(found);
else api.note(asked).then(open).catch(() => {});
@@ -91,17 +125,57 @@
try {
notes = (await api.notes(q ? { q } : {})).notes ?? [];
status = 'ready';
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
} catch (e) {
status = 'failed';
app.toast(humanMessage(e));
}
}
function open(note) {
/**
* Åbner en note — og henter den hel først, hvis listen kun har uddraget.
*
* Listen bærer `preview` og en tom `body`, så en liste på tolv hundrede noter
* ikke er tolv hundrede hele noter. Editoren må aldrig få det uddrag: den ville
* gemme det efter sin pause, og noten ville være skåret ned til sit eget uddrag
* af ingenting andet end at være blevet kigget på.
*
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
* at vente; teksten kommer, når den er der.
*/
async function open(note) {
clearTimeout(timer);
selected = note;
draft = note?.body ?? '';
if (!note) {
selectedId = null;
selected = null;
draft = '';
return;
}
// Rækken fremhæves med det samme; editoren får først noten, når den er hel.
//
// De to er skilt ad, fordi editoren indlæser sin tekst, når notens *id*
// skifter — og hvis den fik uddraget først og hele noten bagefter, ville
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
// sidde med en tom note, der lige havde haft en titel.
selectedId = note.id;
if (note.body) {
selected = note;
draft = note.body;
return;
}
selected = null;
try {
const full = await api.note(note.id);
// Kun hvis den stadig er den, der er valgt: skifter man note, mens
// hentningen er undervejs, må den forrige ikke lande oven i den nye.
if (selectedId !== note.id) return;
selected = full;
draft = full.body ?? '';
} catch (e) {
app.toast(humanMessage(e));
}
}
/**
@@ -151,6 +225,14 @@
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
}
/** Hvilken dato rækken viser: den, der sorteres efter. */
const shownDate = (note) => (order === 'created' ? note.created_at : note.updated_at);
/** Begge datoer, til den, der holder musen stille. */
const stamp = (note) =>
`${t('notes.sortCreated')}: ${new Date(note.created_at).toLocaleString(tag())}\n` +
`${t('notes.sortUpdated')}: ${new Date(note.updated_at).toLocaleString(tag())}`;
function projectName(id) {
return app.projects.find((p) => p.id === id)?.name ?? '';
}
@@ -194,7 +276,10 @@
saving = true;
try {
const saved = await api.updateNote(id, { body });
notes = notes.map((n) => (n.id === id ? saved : n));
// Svaret er hele noten. Rækken viser `preview`, så den skal med — ellers
// står linjen tom, indtil siden hentes igen.
const forList = { ...saved, preview: saved.body ?? '' };
notes = notes.map((n) => (n.id === id ? forList : 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;
@@ -299,13 +384,29 @@
</div>
</div>
<input
class="search"
type="search"
bind:value={query}
placeholder={t('notes.search')}
aria-label={t('notes.search')}
/>
<!-- Samme form som søgefeltet i toppen: samme flade, samme kant, samme
radius. Det var en pille med et forstørrelsesglas tegnet ind som et
baggrundsbillede med farven skrevet i hånden — så det var det ene sted i
programmet, der ikke fulgte temaet, og det kunne ses. -->
<div class="searchbox" class:busy={typing}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M20 20l-3.5-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<input
type="search"
value={draftQuery}
oninput={(e) => search(e.currentTarget.value)}
placeholder={t('notes.search')}
aria-label={t('notes.search')}
/>
{#if draftQuery}
<button class="clear" onclick={() => search('')} aria-label={t('notes.clearSearch')}>×</button>
{/if}
</div>
{#if query && status === 'ready'}
<p class="found">{t('notes.found', { count: notes.length })}</p>
{/if}
{#if status === 'loading'}
<p class="hint">{t('history.loading')}</p>
@@ -318,7 +419,7 @@
<div class="rowline">
<button
class="row"
class:on={selected?.id === note.id}
class:on={selectedId === note.id}
onclick={() => open(note)}
draggable="true"
ondragstart={(e) => onDragStart(e, note)}
@@ -329,8 +430,13 @@
linjer pr. note frem for tre, og datoen er dét, man skanner
efter, når man leder efter noget, man skrev i tirsdags. -->
<span class="under">
<span class="when">{when(note.updated_at)}</span>
<span class="preview">{plain(note.body)}</span>
<!-- Datoen, der svarer til den rækkefølge, listen står i.
Den viste altid `updated_at`, og for tolv hundrede
importerede noter er det i aftes — så en note fra 2017
stod som "i går", og den dato, man sorterede efter, var
ingen steder at se. -->
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
<span class="preview">{plain(note.preview ?? note.body)}</span>
</span>
{#if note.project_id}
<span class="filed">{projectName(note.project_id)}</span>
@@ -474,21 +580,74 @@
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
udgave, og den ser ens ud hele tiden. */
.search {
width: 100%;
appearance: none;
-webkit-appearance: none;
padding-left: 30px;
border-radius: 999px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b918d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-3.5-3.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 9px center;
background-size: 15px 15px;
/* Formet som søgefeltet i toppen — samme flade, kant og radius — så de to
søgefelter på skærmen ligner hinanden i stedet for at være to beslutninger.
Ikonet er en rigtig SVG med `currentColor`, ikke et baggrundsbillede med en
farve skrevet ind: den gamle var #8b918d uanset tema. */
.searchbox {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s2) var(--s3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-faint);
transition: border-color var(--fast) var(--ease);
}
/* Safaris egen ryd-knap ville sidde oven i vores egen form. */
.search::-webkit-search-decoration,
.search::-webkit-search-cancel-button {
.searchbox:focus-within {
border-color: var(--line-strong);
}
/* Mens det tastede endnu ikke er søgt efter. Et hint om at der kommer noget,
frem for en liste, der ser forkert ud i en kvart sekund. */
.searchbox.busy {
border-color: var(--accent);
}
.searchbox svg {
width: 15px;
height: 15px;
flex: none;
}
.searchbox input {
flex: 1;
min-width: 0;
appearance: none;
-webkit-appearance: none;
border: 0;
background: none;
color: var(--ink);
font-size: var(--text-sm);
outline: none;
}
.clear {
flex: none;
width: 18px;
height: 18px;
border-radius: var(--radius-full);
color: var(--ink-faint);
font-size: 15px;
line-height: 1;
}
.clear:hover {
background: var(--surface-sunken);
color: var(--ink);
}
.found {
font-size: var(--text-xs);
color: var(--ink-faint);
padding: 0 var(--s1);
}
/* Safaris egen ryd-knap ville sidde oven i vores egen. */
.searchbox input::-webkit-search-decoration,
.searchbox input::-webkit-search-cancel-button {
-webkit-appearance: none;
}
@@ -500,6 +659,25 @@
min-height: 0;
}
/* Rækker uden for skærmen koster ingenting.
*
* Listen tegner alle noterne. Med ti var det ligegyldigt; med tolv hundrede tog
* første tegning to sekunder, og browseren gik i knæ — for hver eneste række
* blev der lavet layout og malet, også de tusind, ingen kan se.
*
* `content-visibility: auto` lader browseren springe netop det over for det,
* der er uden for billedet, og tage det, når man ruller derhen. Ingen JavaScript,
* ingen rullematematik, ingen liste, der kan komme ud af trit med sig selv —
* hvilket er hele grunden til at prøve det her før en vinduesvisning.
*
* `contain-intrinsic-size` er gættet på, hvor høj en uset række er. Uden det
* ville rullebjælken tro, at listen er tom, og hoppe, mens man ruller. Tallet er
* målt på en rigtig række: titel plus én linje uddrag. */
li {
content-visibility: auto;
contain-intrinsic-size: auto 54px;
}
.rowline {
display: flex;
align-items: center;
+3 -1
View File
@@ -868,7 +868,9 @@
<li>
<a href="/noter?note={note.id}">
<strong>{note.title || t('notes.untitled')}</strong>
<span>{note.body.slice(0, 90)}</span>
<!-- `preview` når listen bærer det, `body` når noten kom
fra et sted, der sender den hel. -->
<span>{(note.preview ?? note.body ?? '').slice(0, 90)}</span>
</a>
</li>
{/each}