Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aad7c13b0 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -72,7 +72,33 @@
|
||||
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);
|
||||
|
||||
@@ -89,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(() => {});
|
||||
@@ -99,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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,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;
|
||||
@@ -315,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>
|
||||
@@ -334,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)}
|
||||
@@ -351,7 +436,7 @@
|
||||
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>
|
||||
@@ -495,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;
|
||||
}
|
||||
|
||||
@@ -521,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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user