Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aad7c13b0 | ||
|
|
3a02bc8304 |
@@ -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.
|
||||
|
||||
+46
-7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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