Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1437667483 | ||
|
|
035d079a8e | ||
|
|
aadfb197b6 | ||
|
|
8aad7c13b0 |
@@ -1396,6 +1396,57 @@ paths:
|
|||||||
description: The environment variables that are not set.
|
description: The environment variables that are not set.
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"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:
|
/api/v1/notes/{noteID}/attachments:
|
||||||
post:
|
post:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "search notes", err)
|
s.internal(w, r, "search notes", err)
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "notes in project", err)
|
s.internal(w, r, "notes in project", err)
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +87,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "list notes", err)
|
s.internal(w, r, "list notes", err)
|
||||||
return
|
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.
|
// 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))
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
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)
|
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||||
notes := listed["notes"].([]any)
|
notes := listed["notes"].([]any)
|
||||||
if len(notes) != 1 {
|
if len(notes) != 1 {
|
||||||
t.Fatalf("found %d notes", len(notes))
|
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
|
// The link points at the file where it now lives, not at a path inside an
|
||||||
// archive nobody kept.
|
// archive nobody kept.
|
||||||
|
|||||||
@@ -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
|
// files in and nothing else could, which made the most ordinary act
|
||||||
// in a notes app — paste a screenshot — do nothing at all.
|
// in a notes app — paste a screenshot — do nothing at all.
|
||||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
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) {
|
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
@@ -10,15 +10,26 @@ import (
|
|||||||
|
|
||||||
// Note is a page of Markdown belonging to a project, or to nobody.
|
// Note is a page of Markdown belonging to a project, or to nobody.
|
||||||
type Note struct {
|
type Note struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
CreatedBy string `json:"created_by,omitempty"`
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
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
|
// 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
|
// 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,
|
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.
|
// 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).
|
// 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.
|
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
|
||||||
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
|
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
|
// 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
|
// 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
|
// 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.
|
// deleting — which is exactly how it read before this existed.
|
||||||
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||||
return db.notesWhere(ctx, `
|
return db.allNotes(ctx, userID, false)
|
||||||
WHERE deleted_at IS NULL
|
}
|
||||||
|
|
||||||
|
// 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 (
|
AND (
|
||||||
(project_id IS NULL AND created_by = ?)
|
(project_id IS NULL AND created_by = ?)
|
||||||
OR project_id IN (
|
OR project_id IN (
|
||||||
@@ -301,6 +328,27 @@ func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time
|
|||||||
return err
|
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.
|
// DeleteNote puts it in the trash. Nothing here removes a row.
|
||||||
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
@@ -364,10 +412,10 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
var n Note
|
var n Note
|
||||||
var projectID, createdBy sql.NullString
|
var projectID, createdBy sql.NullString
|
||||||
var created, updated int64
|
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,
|
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
|
return n, err
|
||||||
}
|
}
|
||||||
n.ProjectID = projectID.String
|
n.ProjectID = projectID.String
|
||||||
@@ -378,5 +426,9 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
t := time.Unix(deleted.Int64, 0)
|
t := time.Unix(deleted.Int64, 0)
|
||||||
n.DeletedAt = &t
|
n.DeletedAt = &t
|
||||||
}
|
}
|
||||||
|
if archived.Valid {
|
||||||
|
t := time.Unix(archived.Int64, 0)
|
||||||
|
n.ArchivedAt = &t
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2794,3 +2794,103 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ export const api = {
|
|||||||
|
|
||||||
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
||||||
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
||||||
|
noteBulk: (body) => post('/notes/bulk', body),
|
||||||
deleteAttachment: (id) => del(`/attachments/${id}`),
|
deleteAttachment: (id) => del(`/attachments/${id}`),
|
||||||
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
||||||
|
|
||||||
|
|||||||
@@ -121,12 +121,71 @@
|
|||||||
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
||||||
* man læser den.
|
* 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() {
|
function colourCode() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const inside = currentBlock();
|
const inside = currentBlock();
|
||||||
for (const pre of editor.querySelectorAll('pre')) {
|
for (const pre of editor.querySelectorAll('pre')) {
|
||||||
if (pre === inside || pre.contains(inside)) continue;
|
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);
|
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||||
if (lang) pre.setAttribute('data-lang', lang);
|
if (lang) pre.setAttribute('data-lang', lang);
|
||||||
const painted = highlight(code, lang);
|
const painted = highlight(code, lang);
|
||||||
@@ -135,6 +194,7 @@
|
|||||||
const target = pre.querySelector('code') ?? pre;
|
const target = pre.querySelector('code') ?? pre;
|
||||||
if (target.innerHTML !== painted) target.innerHTML = painted;
|
if (target.innerHTML !== painted) target.innerHTML = painted;
|
||||||
}
|
}
|
||||||
|
addCopyButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changed() {
|
function changed() {
|
||||||
@@ -814,7 +874,17 @@
|
|||||||
*
|
*
|
||||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||||
* blive i den. */
|
* 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%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,6 +985,40 @@
|
|||||||
color: var(--ink-muted);
|
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.
|
/* 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
|
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,
|
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
|
||||||
|
|||||||
@@ -380,9 +380,12 @@
|
|||||||
// The same target takes both. A note dropped on a project is filed there,
|
// 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
|
// which is also how it gets shared — one gesture, one meaning, and the
|
||||||
// sidebar already reads as "put this here".
|
// sidebar already reads as "put this here".
|
||||||
const noteID = dragged(event, NOTE);
|
// Nyttelasten er ét eller flere id'er adskilt af mellemrum. Markerer man
|
||||||
if (noteID) {
|
// halvtreds noter og trækker dem herhen, hører de alle sammen til det ene
|
||||||
await app.moveNoteToProject(noteID, project.id);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
'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.newHere': 'Ny note',
|
||||||
'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.',
|
'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.strike': 'Gennemstreget',
|
||||||
'notes.showSource': 'Vis som kode',
|
'notes.showSource': 'Vis som kode',
|
||||||
'notes.source': 'Notens kildetekst',
|
'notes.source': 'Notens kildetekst',
|
||||||
|
|||||||
@@ -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.',
|
'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.newHere': 'New note',
|
||||||
'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.',
|
'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.strike': 'Strikethrough',
|
||||||
'notes.showSource': 'Show as code',
|
'notes.showSource': 'Show as code',
|
||||||
'notes.source': 'Note source',
|
'notes.source': 'Note source',
|
||||||
|
|||||||
@@ -339,7 +339,15 @@ export function htmlToMarkdown(root) {
|
|||||||
// else and an indent does not.
|
// else and an indent does not.
|
||||||
const lang = child.getAttribute('data-lang') ?? '';
|
const lang = child.getAttribute('data-lang') ?? '';
|
||||||
emit('```' + 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(l);
|
||||||
}
|
}
|
||||||
emit('```');
|
emit('```');
|
||||||
|
|||||||
@@ -72,7 +72,104 @@
|
|||||||
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
||||||
});
|
});
|
||||||
let selected = $state(null);
|
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('');
|
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 status = $state('loading');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
@@ -82,6 +179,7 @@
|
|||||||
let timer;
|
let timer;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
showArchive;
|
||||||
load(query);
|
load(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +187,7 @@
|
|||||||
// opens it rather than dropping the person on a list to find it again.
|
// opens it rather than dropping the person on a list to find it again.
|
||||||
let asked = $derived($page.url.searchParams.get('note'));
|
let asked = $derived($page.url.searchParams.get('note'));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!asked || selected?.id === asked) return;
|
if (!asked || selectedId === asked) return;
|
||||||
const found = notes.find((n) => n.id === asked);
|
const found = notes.find((n) => n.id === asked);
|
||||||
if (found) open(found);
|
if (found) open(found);
|
||||||
else api.note(asked).then(open).catch(() => {});
|
else api.note(asked).then(open).catch(() => {});
|
||||||
@@ -97,19 +195,60 @@
|
|||||||
|
|
||||||
async function load(q) {
|
async function load(q) {
|
||||||
try {
|
try {
|
||||||
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
const params = q ? { q } : showArchive ? { archived: '1' } : {};
|
||||||
|
notes = (await api.notes(params)).notes ?? [];
|
||||||
status = 'ready';
|
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) {
|
} catch (e) {
|
||||||
status = 'failed';
|
status = 'failed';
|
||||||
app.toast(humanMessage(e));
|
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);
|
clearTimeout(timer);
|
||||||
selected = note;
|
if (!note) {
|
||||||
draft = note?.body ?? '';
|
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) {
|
function onDragStart(event, note) {
|
||||||
dragging = note.id;
|
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() {
|
async function create() {
|
||||||
@@ -210,7 +353,10 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const saved = await api.updateNote(id, { body });
|
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
|
// 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.
|
// drag the previous one's title back onto this one.
|
||||||
if (selected?.id === id) selected = saved;
|
if (selected?.id === id) selected = saved;
|
||||||
@@ -311,22 +457,63 @@
|
|||||||
<option value={o.key}>{t(o.label)}</option>
|
<option value={o.key}>{t(o.label)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</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>
|
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<!-- Samme form som søgefeltet i toppen: samme flade, samme kant, samme
|
||||||
class="search"
|
radius. Det var en pille med et forstørrelsesglas tegnet ind som et
|
||||||
type="search"
|
baggrundsbillede med farven skrevet i hånden — så det var det ene sted i
|
||||||
bind:value={query}
|
programmet, der ikke fulgte temaet, og det kunne ses. -->
|
||||||
placeholder={t('notes.search')}
|
<div class="searchbox" class:busy={typing}>
|
||||||
aria-label={t('notes.search')}
|
<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'}
|
{#if status === 'loading'}
|
||||||
<p class="hint">{t('history.loading')}</p>
|
<p class="hint">{t('history.loading')}</p>
|
||||||
{:else if !notes.length}
|
{: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}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#each ordered as note (note.id)}
|
{#each ordered as note (note.id)}
|
||||||
@@ -334,8 +521,9 @@
|
|||||||
<div class="rowline">
|
<div class="rowline">
|
||||||
<button
|
<button
|
||||||
class="row"
|
class="row"
|
||||||
class:on={selected?.id === note.id}
|
class:on={selectedId === note.id}
|
||||||
onclick={() => open(note)}
|
class:picked={picked.has(note.id)}
|
||||||
|
onclick={(e) => rowClick(e, note)}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => onDragStart(e, note)}
|
ondragstart={(e) => onDragStart(e, note)}
|
||||||
ondragend={() => (dragging = null)}
|
ondragend={() => (dragging = null)}
|
||||||
@@ -351,12 +539,30 @@
|
|||||||
stod som "i går", og den dato, man sorterede efter, var
|
stod som "i går", og den dato, man sorterede efter, var
|
||||||
ingen steder at se. -->
|
ingen steder at se. -->
|
||||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
<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>
|
</span>
|
||||||
{#if note.project_id}
|
{#if note.project_id}
|
||||||
<span class="filed">{projectName(note.project_id)}</span>
|
<span class="filed">{projectName(note.project_id)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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
|
<button
|
||||||
class="star"
|
class="star"
|
||||||
class:on={note.pinned}
|
class:on={note.pinned}
|
||||||
@@ -367,6 +573,7 @@
|
|||||||
>
|
>
|
||||||
{note.pinned ? '★' : '☆'}
|
{note.pinned ? '★' : '☆'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -495,21 +702,164 @@
|
|||||||
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
|
— 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
|
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
|
||||||
udgave, og den ser ens ud hele tiden. */
|
udgave, og den ser ens ud hele tiden. */
|
||||||
.search {
|
/* Formet som søgefeltet i toppen — samme flade, kant og radius — så de to
|
||||||
width: 100%;
|
søgefelter på skærmen ligner hinanden i stedet for at være to beslutninger.
|
||||||
appearance: none;
|
Ikonet er en rigtig SVG med `currentColor`, ikke et baggrundsbillede med en
|
||||||
-webkit-appearance: none;
|
farve skrevet ind: den gamle var #8b918d uanset tema. */
|
||||||
padding-left: 30px;
|
.searchbox {
|
||||||
border-radius: 999px;
|
display: flex;
|
||||||
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");
|
align-items: center;
|
||||||
background-repeat: no-repeat;
|
gap: var(--s2);
|
||||||
background-position: 9px center;
|
padding: var(--s2) var(--s3);
|
||||||
background-size: 15px 15px;
|
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. */
|
.searchbox:focus-within {
|
||||||
.search::-webkit-search-decoration,
|
border-color: var(--line-strong);
|
||||||
.search::-webkit-search-cancel-button {
|
}
|
||||||
|
|
||||||
|
/* 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;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,15 +871,45 @@
|
|||||||
min-height: 0;
|
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 {
|
.rowline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s1);
|
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 {
|
.star {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 24px;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|||||||
@@ -868,7 +868,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/noter?note={note.id}">
|
<a href="/noter?note={note.id}">
|
||||||
<strong>{note.title || t('notes.untitled')}</strong>
|
<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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user