Compare commits

..
5 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 6c3d66108b fix(look): stregen under hurtig tilføjelse havde ingen luft under sig
Release / Build and publish image (push) Failing after 9m56s
Den skiller "skriv en ny" fra "det, der allerede er". Uden afstand sad den første
sektion klods op ad den, så den læste som en kant om sektionen frem for som en
afslutning på feltet over. En streg skal have plads på begge sider for at kunne
ses som en streg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:36:23 +02:00
Kristian WindandClaude Opus 5 1437667483 fix(notes): stjerne og arkiv står oven på hinanden
Release / Build and publish image (push) Failing after 9m56s
To knapper ved siden af hinanden er toogfyrre pixels taget fra en liste, der er
tre hundrede bred — og de er det, man rører en gang imellem, mens titlen og
uddraget er det, man læser hele tiden. I en søjle koster de toogtyve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:23:21 +02:00
Kristian WindandClaude Opus 5 035d079a8e fix(notes): teksten fulgte ikke med, når ruden blev bred
Release / Build and publish image (push) Failing after 9m59s
46rem var sat for at holde linjelængden i skak, og det er stadig det rigtige
hensyn — men det blev sat som ét tal, og på en skærm halvanden gang så bred stod
teksten i en smal søjle med en håndflade tom plads ved siden af. Kodeblokke og
billeder, som er de to ting i en note, der *vil* have bredde, blev klemt sammen
uden grund.

clamp frem for et tal: en smal rude får det hele, en bred får en linjelængde,
øjet kan finde tilbage i. På 1920 er tekstsøjlen nu 1088 px af arkets 1304 i
stedet for 736.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:08:26 +02:00
Kristian WindandClaude Opus 5 aadfb197b6 feat(notes): arkiv, flere ad gangen, og en kopiér-knap på koden
Release / Build and publish image (push) Failing after 9m55s
**Arkivet, fordi papirkurven er det forkerte redskab.** Den siger "det her var en
fejl, og om tredive dage er det væk" — så alt, der er værd at beholde, kan ikke
komme i den. Et arkiv på tolv hundrede importerede noter er præcis det tilfælde,
hvor de fleste er værd at beholde og næsten ingen er værd at kigge på; uden et
sted at lægge dem er listen hele arkivet for evigt, og at finde ugens note
betyder at læse forbi et årti.

En kolonne, ikke en mappe. Noterne afviste et andet hierarki i 0018, og
arkivering er ikke arkivering i den forstand: det er én oplysning om, hvorvidt en
note er aktuel, og den skal kunne besvares ved siden af `deleted_at`, som er den
anden af samme slags. `updated_at` røres ikke — at rydde op er ikke at skrive.

**Flere ad gangen.** ⌘-klik lægger til, skift-klik tager intervallet i den
rækkefølge, listen faktisk står i — ikke notens egen, som er en anden, når der er
sorteret eller søgt. Trækker man en note, der er markeret, følger hele
markeringen med; trækker man en, der ikke er, er det den ene, for ellers ville et
træk i en tilfældig række flytte halvtreds noter, man havde glemt var markeret.

Ét kald frem for et pr. note: efter en import af tolv hundrede er det at vælge
halvtreds det almindelige tilfælde. Hver note kontrolleres stadig for sig — et sæt
er en bekvemmelighed for den, der kalder, og aldrig en vej til noget, de ikke
kunne have rørt én ad gangen.

En kant *og* en tone på det markerede: `--accent-sunken` er meget lys i lyst tema,
og tre markerede rækker var svære at skelne fra tre, der ikke var — en dårlig ting
at være i tvivl om lige inden man trykker Slet.

**Kopiér-knappen spiste næsten den kode, den skulle kopiere.** Den ligger inde i
`<pre>` for at kunne stå i hjørnet af blokken, og `colourCode` læste
`pre.textContent` og malede resultatet tilbage — så et klik på "Kopiér" skrev
"Kopieret" ind i koden, og næste gemning lagde det i filen. Nu ét sted, der siger
hvad en kodeblok indeholder, brugt af både farvelægningen og oversættelsen til
Markdown: var de to uenige, ville forskellen ende i noten. Prøven klikker,
læser udklipsholderen og læser filen bagefter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:03:02 +02:00
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 <noreply@anthropic.com>
2026-08-20 10:36:35 +02:00
16 changed files with 944 additions and 59 deletions
+51
View File
@@ -1396,6 +1396,57 @@ paths:
description: The environment variables that are not set.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/notes/bulk:
post:
tags: [notes]
summary: Archive, unarchive or delete a set of notes in one call.
description: |
One call rather than one per note, because the thing being asked is one
thing: "put these away". Fifty separate requests would be fifty round
trips, fifty chances for half of them to fail, and a list that redraws
fifty times — and after an import of twelve hundred notes, selecting fifty
is the ordinary case rather than the extreme one.
Each note is still checked on its own. A set is a convenience for the
caller and never a way to touch something they could not have touched one
at a time; anything they may not write to is counted in `skipped` rather
than failing the whole call, so one note somebody else just deleted does
not stop the other forty-nine.
Archiving is not deleting. The trash says "this was a mistake, and in
thirty days it is gone"; the archive says "this is finished, and I want to
keep it".
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
items: { type: string }
description: At most 500.
archived:
type: boolean
description: Put away, or bring back out.
delete:
type: boolean
description: Into the trash instead. Wins over `archived`.
responses:
"200":
description: How many were changed, and how many were passed over.
content:
application/json:
schema:
type: object
properties:
done: { type: integer }
skipped: { type: integer }
"422":
description: No ids, too many, or neither archived nor delete was asked for.
/api/v1/notes/{noteID}/attachments:
post:
tags: [notes]
+121 -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,20 @@ 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
}
// The archive is the same list asked the other way round, so it is the same
// endpoint with a flag rather than a second one that would have to be kept in
// step with this.
if r.URL.Query().Get("archived") != "" {
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list archived notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
return
}
@@ -74,7 +87,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.
@@ -687,3 +734,74 @@ func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Reque
}
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
}
// --- putting notes away, one or many ---------------------------------------------
type noteBulkRequest struct {
IDs []string `json:"ids"`
Archived *bool `json:"archived"`
Delete bool `json:"delete"`
}
// maxNoteBulk bounds one call. Enough to select a screenful several times over,
// and few enough that the loop below cannot become somebody's afternoon.
const maxNoteBulk = 500
// handleNoteBulk archives, unarchives or deletes a set of notes in one request.
//
// One call rather than one per note, because the thing being asked is one thing:
// "put these away". Fifty separate requests would be fifty round trips, fifty
// chances for half of them to fail, and a list that redraws fifty times — and
// after an import of twelve hundred notes, selecting fifty is the ordinary case,
// not the extreme one.
//
// Each note is still checked on its own. A set is a convenience for the caller
// and must never become a way to touch something they could not have touched one
// at a time.
func (s *Server) handleNoteBulk(w http.ResponseWriter, r *http.Request) {
var req noteBulkRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if len(req.IDs) == 0 {
writeFieldErrors(w, map[string]string{"ids": "required"})
return
}
if len(req.IDs) > maxNoteBulk {
writeFieldErrors(w, map[string]string{"ids": "too many at once"})
return
}
done, skipped := 0, 0
for _, id := range req.IDs {
n, err := s.db.Note(r.Context(), id)
if err != nil {
s.internal(w, r, "get note", err)
return
}
// Not an error, and not a 404 for the whole call: a set can contain a note
// somebody else deleted a second ago, and the other forty-nine should still
// be put away. Counted so the answer is honest about it.
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
skipped++
continue
}
switch {
case req.Delete:
err = s.db.DeleteNote(r.Context(), id)
case req.Archived != nil:
err = s.db.SetNoteArchived(r.Context(), id, *req.Archived)
default:
writeFieldErrors(w, map[string]string{"archived": "say archived or delete"})
return
}
if err != nil {
s.internal(w, r, "note bulk", err)
return
}
done++
}
writeJSON(w, http.StatusOK, map[string]any{"done": done, "skipped": skipped})
}
+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.
+3
View File
@@ -333,6 +333,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// files in and nothing else could, which made the most ordinary act
// in a notes app — paste a screenshot — do nothing at all.
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
// Mange på én gang. Efter en import af tolv hundrede noter er det at
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
r.Post("/bulk", s.handleNoteBulk)
})
r.Route("/mailboxes", func(r chi.Router) {
@@ -0,0 +1,23 @@
-- A note can be put away without being thrown away.
--
-- The trash already exists and is the wrong tool. It says "this was a mistake,
-- and in thirty days it is gone" — so anything worth keeping cannot go in it, and
-- an archive of twelve hundred imported notes is exactly the case where most of
-- them are worth keeping and almost none are worth looking at. Without somewhere
-- to put them, the list is the whole archive forever, and finding this week's note
-- means reading past a decade.
--
-- A column rather than a folder. Verdande already refused a second hierarchy for
-- notes — see 0018 — and archiving is not filing: it is one bit about whether a
-- note is current, and it has to be answerable next to `deleted_at`, which is the
-- other bit of the same kind.
--
-- Nullable timestamp rather than a boolean, for the same reason `deleted_at` is
-- one: "when was this put away" answers questions a flag cannot, and it costs the
-- same byte.
ALTER TABLE notes ADD COLUMN archived_at INTEGER;
-- The list asks "not deleted, not archived, newest first" on every load, and with
-- twelve hundred notes that is the query that has to be quick.
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
WHERE deleted_at IS NULL;
+67 -15
View File
@@ -10,15 +10,26 @@ import (
// Note is a page of Markdown belonging to a project, or to nobody.
type Note struct {
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
CreatedBy string `json:"created_by,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
CreatedBy string `json:"created_by,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
ArchivedAt *time.Time `json:"archived_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
@@ -33,7 +44,7 @@ type NoteLink struct {
}
const noteColumns = `id, project_id, title, body, created_by, pinned,
created_at, updated_at, deleted_at`
created_at, updated_at, deleted_at, archived_at`
// The same columns, said with the table in front of them.
//
@@ -41,7 +52,8 @@ const noteColumns = `id, project_id, title, body, created_by, pinned,
// 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`
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at,
notes.archived_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
@@ -207,8 +219,23 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
// shared, and a list that dropped it at that moment would make sharing look like
// deleting — which is exactly how it read before this existed.
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
return db.allNotes(ctx, userID, false)
}
// ArchivedNotes is the same list, the other way round: what has been put away.
func (db *DB) ArchivedNotes(ctx context.Context, userID string) ([]Note, error) {
return db.allNotes(ctx, userID, true)
}
func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Note, error) {
// Archived notes are left out of the ordinary list rather than mixed in and
// greyed: the point of putting something away is not seeing it.
state := "notes.archived_at IS NULL"
if archived {
state = "notes.archived_at IS NOT NULL"
}
return db.notesJoin(ctx, noteColumnsQualified, `
WHERE `+state+` AND deleted_at IS NULL
AND (
(project_id IS NULL AND created_by = ?)
OR project_id IN (
@@ -301,6 +328,27 @@ func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time
return err
}
// SetNoteArchived puts a note away, or takes it back out.
//
// Separate from DeleteNote because they mean different things and the difference
// is the whole feature: the trash says "this was a mistake, and in thirty days it
// is gone", and the archive says "this is finished, and I want to keep it". A
// twelve-hundred-note import is mostly the second, and without somewhere to put
// them the list is the whole archive forever.
//
// `updated_at` is deliberately not touched. Archiving is not editing, and a list
// sorted by when things were last written should not be reordered by somebody
// tidying up.
func (db *DB) SetNoteArchived(ctx context.Context, id string, archived bool) error {
var at any
if archived {
at = time.Now().Unix()
}
_, err := db.ExecContext(ctx,
`UPDATE notes SET archived_at = ? WHERE id = ? AND deleted_at IS NULL`, at, id)
return err
}
// DeleteNote puts it in the trash. Nothing here removes a row.
func (db *DB) DeleteNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
@@ -364,10 +412,10 @@ func scanNote(row scanner) (Note, error) {
var n Note
var projectID, createdBy sql.NullString
var created, updated int64
var deleted sql.NullInt64
var deleted, archived sql.NullInt64
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
&n.Pinned, &created, &updated, &deleted); err != nil {
&n.Pinned, &created, &updated, &deleted, &archived); err != nil {
return n, err
}
n.ProjectID = projectID.String
@@ -378,5 +426,9 @@ func scanNote(row scanner) (Note, error) {
t := time.Unix(deleted.Int64, 0)
n.DeletedAt = &t
}
if archived.Valid {
t := time.Unix(archived.Int64, 0)
n.ArchivedAt = &t
}
return n, nil
}
+100
View File
@@ -2794,3 +2794,103 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
expect(trouble).toEqual([]);
});
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/noter');
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: '# Kommandoen\n\n```bash\ndocker pull verdande\n```\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const copy = ed.locator('pre .copy');
await expect(copy).toBeVisible();
await copy.click();
await expect(copy).toHaveText('Kopieret');
// Det, der ligger i udklipsholderen, er koden — ikke knappens eget ord.
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toBe('docker pull verdande');
// Og noten selv må ikke få "Kopiér" skrevet ind i sig, når den gemmes. Knappen
// ligger inde i <pre> for at kunne stå i hjørnet af den, så det er præcis den
// fælde, den her prøve findes for.
await ed.click();
await page.keyboard.press('End');
await page.keyboard.type(' ');
await page.waitForTimeout(1200);
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()).body;
}, id);
expect(body).toContain('docker pull verdande');
expect(body).not.toContain('Kopiér');
expect(body).not.toContain('Kopieret');
expect(trouble).toEqual([]);
});
test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
for (const title of ['Alfa', 'Bravo', 'Charlie']) {
await page.evaluate(async (t) => {
await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: `# ${t}\n\nEn note.\n` })
});
}, title);
}
await page.reload();
const rows = page.locator('.notes button.row');
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
// To af tre: et almindeligt klik, og et ⌘-klik oven i. Den, der lige er åbnet,
// hører med i markeringen — ellers ville det andet klik markere én og glemme
// den, man stod på.
await rows.filter({ hasText: 'Alfa' }).click();
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
await expect(page.getByText('2 markeret')).toBeVisible();
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
await page.waitForTimeout(900);
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
await expect(rows.filter({ hasText: 'Charlie' })).toBeVisible();
await page.getByLabel('Vis arkivet').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
await expect(rows.filter({ hasText: 'Bravo' })).toBeVisible();
await expect(rows.filter({ hasText: 'Charlie' })).toHaveCount(0);
// Og tilbage igen — med rækkens egen knap, som er vejen for den ene. Et
// almindeligt klik rydder markeringen med vilje, så bjælken er til flere.
await rows.filter({ hasText: 'Alfa' }).hover();
await page.locator('.rowline', { hasText: 'Alfa' }).getByLabel('Tag frem igen').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
await page.getByLabel('Vis arkivet').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
expect(trouble).toEqual([]);
});
+1
View File
@@ -345,6 +345,7 @@ export const api = {
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
noteBulk: (body) => post('/notes/bulk', body),
deleteAttachment: (id) => del(`/attachments/${id}`),
attachmentURL: (id) => `/api/v1/attachments/${id}`,
+106 -2
View File
@@ -121,12 +121,71 @@
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
* man læser den.
*/
/**
* En kopiér-knap på hver kodeblok.
*
* Lagt oven på blokken frem for skrevet ind i den: alt inde i `.page` er tekst,
* der bliver til Markdown, når noten gemmes, så en knap derinde ville ende i
* filen. Den her er `contenteditable="false"` og hænger uden for tekstflowet —
* den findes for øjet og musen, ikke for dokumentet.
*
* `navigator.clipboard` fejler, når siden ikke er sikker eller ikke har fokus,
* og en knap, der siger "Kopieret" uden at have gjort det, er værre end ingen
* knap. Derfor siges det kun, når skrivningen faktisk lykkedes.
*/
function addCopyButtons() {
if (!editor) return;
for (const pre of editor.querySelectorAll('pre')) {
if (pre.querySelector('.copy')) continue;
const btn = document.createElement('button');
btn.className = 'copy';
btn.type = 'button';
btn.contentEditable = 'false';
btn.textContent = t('notes.copyCode');
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const text = codeTextOf(pre);
try {
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
btn.textContent = t('notes.copied');
setTimeout(() => (btn.textContent = t('notes.copyCode')), 1200);
} catch {
app.toast(t('notes.copyFailed'));
}
});
pre.appendChild(btn);
}
}
/**
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
*
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
*/
function codeTextOf(pre) {
const from = pre.querySelector('code') ?? pre;
return [...from.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
}
function colourCode() {
if (!editor) return;
const inside = currentBlock();
for (const pre of editor.querySelectorAll('pre')) {
if (pre === inside || pre.contains(inside)) continue;
const code = pre.textContent ?? '';
// Blokkens tekst uden det, vi selv har lagt oven på den.
//
// `pre.textContent` tog kopiér-knappens eget ord med, og linjen under
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
// spiste den kode, den var sat der for at kopiere.
const code = codeTextOf(pre);
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
if (lang) pre.setAttribute('data-lang', lang);
const painted = highlight(code, lang);
@@ -135,6 +194,7 @@
const target = pre.querySelector('code') ?? pre;
if (target.innerHTML !== painted) target.innerHTML = painted;
}
addCopyButtons();
}
function changed() {
@@ -814,7 +874,17 @@
*
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
* blive i den. */
max-width: 46rem;
/* Bredden vokser med ruden, men ikke i det uendelige.
*
* 46rem var for stramt: på en skærm, der er halvanden gang så bred, stod
* teksten i en smal søjle med en håndflade tom plads ved siden af — og
* kodeblokke og billeder, som er de to ting i en note, der *vil* have
* bredde, blev klemt sammen uden grund.
*
* clamp frem for et tal: en smal rude får det hele, en bred får en
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
max-width: clamp(38rem, 92%, 68rem);
width: 100%;
}
@@ -915,6 +985,40 @@
color: var(--ink-muted);
}
/* Kopiér-knappen, øverst til højre i blokken.
*
* Synlig hele tiden, ikke kun ved hover. En knap, der først findes, når musen
* er det rigtige sted, er en knap, folk ikke ved er der — og på en berøringsskærm
* findes den slet ikke. Den er i stedet dæmpet nok til ikke at tage
* opmærksomhed fra koden.
*
* `z-index` sagt højt: uden det lå den bag blokkens eget indhold i
* stakrækkefølgen, og et klik ramte <pre> i stedet for knappen. */
.page :global(pre) {
position: relative;
}
.page :global(pre .copy) {
position: absolute;
z-index: 1;
top: 6px;
right: 6px;
font-family: var(--font);
font-size: var(--text-xs);
padding: 2px var(--s2);
border-radius: var(--radius-sm);
background: rgb(255 255 255 / 0.08);
color: rgb(255 255 255 / 0.55);
transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.page :global(pre:hover .copy),
.page :global(pre .copy:hover),
.page :global(pre .copy:focus-visible) {
background: rgb(255 255 255 / 0.2);
color: #fff;
}
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
+7
View File
@@ -221,6 +221,13 @@
gap: var(--s3);
padding: var(--s3) var(--s2);
border-bottom: 1px solid var(--line);
/* Luft under stregen.
*
* Stregen skiller "skriv en ny" fra "det, der allerede er", og uden afstand
* sad den første sektion klods op ad den — så den læste som en kant om
* sektionen frem for som en afslutning på feltet over. En streg skal have
* plads på begge sider for at kunne ses som en streg. */
margin-bottom: var(--s4);
}
.plus {
+6 -3
View File
@@ -380,9 +380,12 @@
// The same target takes both. A note dropped on a project is filed there,
// which is also how it gets shared — one gesture, one meaning, and the
// sidebar already reads as "put this here".
const noteID = dragged(event, NOTE);
if (noteID) {
await app.moveNoteToProject(noteID, project.id);
// Nyttelasten er ét eller flere id'er adskilt af mellemrum. Markerer man
// halvtreds noter og trækker dem herhen, hører de alle sammen til det ene
// sted, man slap dem — det er én handling, uanset hvor mange den flytter.
const noteIDs = (dragged(event, NOTE) ?? '').split(' ').filter(Boolean);
if (noteIDs.length) {
for (const id of noteIDs) await app.moveNoteToProject(id, project.id);
return;
}
+13
View File
@@ -46,6 +46,19 @@ 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.picked': '{count} markeret',
'notes.archive': 'Arkivér',
'notes.unarchive': 'Tag frem igen',
'notes.clearPick': 'Ryd markering',
'notes.showArchive': 'Vis arkivet',
'notes.deleteMany': 'Slet {count} noter?',
'notes.archiveOne': 'Arkivér noten',
'notes.emptyArchive': 'Arkivet er tomt.',
'notes.copyCode': 'Kopiér',
'notes.copied': 'Kopieret',
'notes.copyFailed': 'Kunne ikke kopiere',
'notes.strike': 'Gennemstreget',
'notes.showSource': 'Vis som kode',
'notes.source': 'Notens kildetekst',
+13
View File
@@ -42,6 +42,19 @@ 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.picked': '{count} selected',
'notes.archive': 'Archive',
'notes.unarchive': 'Bring back',
'notes.clearPick': 'Clear selection',
'notes.showArchive': 'Show the archive',
'notes.deleteMany': 'Delete {count} notes?',
'notes.archiveOne': 'Archive this note',
'notes.emptyArchive': 'The archive is empty.',
'notes.copyCode': 'Copy',
'notes.copied': 'Copied',
'notes.copyFailed': 'Could not copy',
'notes.strike': 'Strikethrough',
'notes.showSource': 'Show as code',
'notes.source': 'Note source',
+9 -1
View File
@@ -339,7 +339,15 @@ export function htmlToMarkdown(root) {
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
// ind i hver eneste kodeblok, hver gang noten blev gemt.
const code = [...child.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
for (const l of code.replace(/\n$/, '').split('\n')) {
emit(l);
}
emit('```');
+413 -33
View File
@@ -72,7 +72,104 @@
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);
/**
* Flere ad gangen.
*
* Et sæt frem for et flag på noten, fordi markeringen hører til skærmen og ikke
* til noten: den forsvinder, når man går et andet sted hen, og den skal ikke
* gemmes nogen steder.
*
* `anchor` er den, et skift-klik måler fra. Uden den ville et interval skulle
* gættes ud fra "den valgte", og den flytter sig, hver gang man klikker.
*/
let picked = $state(new Set());
let anchor = $state(null);
let showArchive = $state(false);
function rowClick(event, note) {
const many = event.metaKey || event.ctrlKey;
const range = event.shiftKey;
if (!many && !range) {
picked = new Set();
anchor = note.id;
open(note);
return;
}
const next = new Set(picked);
if (range && anchor) {
// Fra ankeret til her, i den rækkefølge listen faktisk står i — ikke i
// notens egen, som er en anden, når der er sorteret eller søgt.
const ids = ordered.map((n) => n.id);
const from = ids.indexOf(anchor);
const to = ids.indexOf(note.id);
if (from !== -1 && to !== -1) {
for (const id of ids.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(id);
}
} else {
// Den, der lige er åbnet, hører med i markeringen: ellers ville et
// ⌘-klik nummer to markere to noter og glemme den, man stod på.
if (!next.size && selectedId) next.add(selectedId);
next.has(note.id) ? next.delete(note.id) : next.add(note.id);
anchor = note.id;
}
picked = next;
}
/** Arkivér, tag frem igen, eller slet — for det, der er markeret. */
async function bulk(change) {
const ids = [...picked];
if (!ids.length) return;
if (change.delete && !confirm(t('notes.deleteMany', { count: ids.length }))) return;
try {
await api.noteBulk({ ids, ...change });
picked = new Set();
if (ids.includes(selectedId)) open(null);
await load(query);
} catch (e) {
app.toast(humanMessage(e));
}
}
/** Én note lagt væk, uden at markere den først. */
async function archiveOne(note) {
try {
await api.noteBulk({ ids: [note.id], archived: !note.archived_at });
if (selectedId === note.id) open(null);
await load(query);
} catch (e) {
app.toast(humanMessage(e));
}
}
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);
@@ -82,6 +179,7 @@
let timer;
$effect(() => {
showArchive;
load(query);
});
@@ -89,7 +187,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(() => {});
@@ -97,19 +195,60 @@
async function load(q) {
try {
notes = (await api.notes(q ? { q } : {})).notes ?? [];
const params = q ? { q } : showArchive ? { archived: '1' } : {};
notes = (await api.notes(params)).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));
}
}
/**
@@ -177,7 +316,11 @@
function onDragStart(event, note) {
dragging = note.id;
startDrag(event, NOTE, note.id);
// Trækker man en note, der er markeret, følger hele markeringen med. Trækker
// man en, der ikke er, er det den ene — ellers ville et træk i en tilfældig
// række flytte halvtreds noter, man havde glemt var markeret.
const ids = picked.has(note.id) ? [...picked] : [note.id];
startDrag(event, NOTE, ids.join(' '));
}
async function create() {
@@ -210,7 +353,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;
@@ -311,22 +457,63 @@
<option value={o.key}>{t(o.label)}</option>
{/each}
</select>
<button
class="archive-toggle"
class:on={showArchive}
onclick={() => { showArchive = !showArchive; picked = new Set(); }}
aria-pressed={showArchive}
aria-label={t('notes.showArchive')}
title={t('notes.showArchive')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
</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 picked.size}
<!-- Kun mens der er markeret noget. En bjælke, der altid står der, er en
bjælke, der tager plads fra listen hver dag for at gøre gavn sjældent. -->
<div class="picked-bar">
<span>{t('notes.picked', { count: picked.size })}</span>
<button onclick={() => bulk({ archived: !showArchive })}>
{showArchive ? t('notes.unarchive') : t('notes.archive')}
</button>
<button class="danger" onclick={() => bulk({ delete: true })}>{t('notes.delete')}</button>
<button onclick={() => (picked = new Set())}>{t('notes.clearPick')}</button>
</div>
{:else 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>
{:else if !notes.length}
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
<p class="hint">
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
</p>
{:else}
<ul>
{#each ordered as note (note.id)}
@@ -334,8 +521,9 @@
<div class="rowline">
<button
class="row"
class:on={selected?.id === note.id}
onclick={() => open(note)}
class:on={selectedId === note.id}
class:picked={picked.has(note.id)}
onclick={(e) => rowClick(e, note)}
draggable="true"
ondragstart={(e) => onDragStart(e, note)}
ondragend={() => (dragging = null)}
@@ -351,12 +539,30 @@
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.body)}</span>
<span class="preview">{plain(note.preview ?? note.body)}</span>
</span>
{#if note.project_id}
<span class="filed">{projectName(note.project_id)}</span>
{/if}
</button>
<!-- De to knapper står oven på hinanden i en smal søjle.
Ved siden af hinanden tog de toogfyrre pixels af en liste,
der er tre hundrede bred — og de bruges sjældent, mens
titlen og uddraget læses hele tiden. -->
<div class="rowactions">
<!-- Én note lagt væk, uden at markere den først. Markeringen er
til flere; det her er til den ene, man står med — og i
arkivet er det vejen tilbage. -->
<button
class="archive-one"
onclick={() => archiveOne(note)}
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button
class="star"
class:on={note.pinned}
@@ -367,6 +573,7 @@
>
{note.pinned ? '★' : '☆'}
</button>
</div>
</div>
</li>
{/each}
@@ -495,21 +702,164 @@
— 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);
}
/* Bjælken over listen, mens der er markeret noget. Den erstatter antallet af
fundne frem for at lægge sig oven i det: kun ét af de to er relevant ad
gangen. */
.picked-bar {
display: flex;
align-items: center;
gap: var(--s2);
flex-wrap: wrap;
padding: var(--s2) var(--s1);
font-size: var(--text-xs);
color: var(--ink-muted);
}
.picked-bar button {
font-size: var(--text-xs);
color: var(--ink);
padding: 2px var(--s2);
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.picked-bar button:hover {
background: var(--surface);
}
.picked-bar button.danger {
color: var(--danger);
border-color: var(--danger-sunken);
}
/* En markeret række.
*
* En kant og en tone, ikke en tone alene: `--accent-sunken` er meget lys i lyst
* tema, og tre markerede rækker var næsten ikke til at skelne fra tre, der ikke
* var — hvilket er en dårlig ting at være i tvivl om, lige inden man trykker
* Slet. Kanten kan ses uanset tema og uanset hvor lys tonen er. */
.row.picked {
background: var(--accent-sunken);
box-shadow: inset 3px 0 0 var(--accent);
}
/* Vises som stjernen: usynlig, indtil rækken er under musen, så listen er
rolig at skanne. */
.archive-one {
flex: none;
width: 22px;
height: 22px;
color: var(--ink-faint);
border-radius: var(--radius-sm);
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.archive-one svg {
width: 14px;
height: 14px;
}
.rowline:hover .archive-one,
.archive-one:focus-visible {
opacity: 1;
}
.archive-one:hover {
color: var(--ink);
background: var(--surface);
}
.archive-toggle {
width: 24px;
height: 24px;
color: var(--ink-faint);
border-radius: var(--radius-sm);
}
.archive-toggle svg {
width: 15px;
height: 15px;
}
.archive-toggle:hover {
color: var(--ink);
background: var(--surface);
}
.archive-toggle.on {
color: var(--accent);
background: var(--accent-sunken);
}
.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;
}
@@ -521,15 +871,45 @@
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;
gap: var(--s1);
}
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
hinanden er toogfyrre pixels taget fra en liste på tre hundrede — og de er
det, man rører en gang imellem, mens titlen er det, man læser hele tiden. */
.rowactions {
display: flex;
flex-direction: column;
gap: 1px;
flex: none;
}
.star {
flex: none;
width: 24px;
width: 22px;
height: 22px;
color: var(--ink-faint);
opacity: 0;
font-size: var(--text-sm);
+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}