Compare commits

...
20 Commits
Author SHA1 Message Date
Kristian Wind fa816b2e41 Merge pull request #27 from kristianwind/ics-alldays-for-dated-tasks
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m52s
CI / Docker image builds (push) Successful in 1m19s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 13m49s
Fix: date-only tasks now appear in a subscribed calendar (ICS)
2026-08-25 08:48:47 +02:00
kristianwindandClaude Opus 5 60d66b728e fix(ics): a date-only task now shows in a subscribed calendar
The calendar feed emitted every task as a VTODO and, on top of that, an event only
for tasks that carried a clock time. But a subscribed calendar in Apple Calendar or
Google ignores VTODO entirely and draws only events — so a task with just a due
date, which is most of them, appeared in the verdande app's own calendar and
nowhere in the calendar it had been subscribed into. "Verdande does not update in my
calendar, only in the verdande app."

Every dated, unfinished task is an event now: a clock time makes a timed event as
before, a bare date an all-day one (a DATE start and an exclusive next-day end, so
it stays on its own day). Finished tasks stay off the calendar, as they were.

Test updated: the sample's timed task and its two date-only tasks all become events
now, the all-day ones in the DATE form, and the finished one still makes none.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 08:42:25 +02:00
Kristian Wind bae3b3505f Merge pull request #26 from kristianwind/fix-apple-web-push-subject
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m12s
CI / Docker image builds (push) Successful in 1m18s
CI / Go (push) Failing after 14m7s
Fix: Apple rejected all push notifications (VAPID subject)
2026-08-25 08:30:45 +02:00
kristianwindandClaude Opus 5 7b1e7ba7c9 fix(push): Apple rejected the notifications; use the site's own URL as VAPID subject
Every Web Push to an iPhone or a Mac was refused, silently as far as the person
waiting for the reminder could tell. The cause was the VAPID "sub" claim: on an
instance with no mail configured it fell back to mailto:[email protected],
and Apple answers 403 Forbidden to a mailto whose domain is made up. Verified
against web.push.apple.com on the live subscriptions — that mailto gets a 403, the
instance's https URL a 201.

So the subject is now a real mailto when SMTP.From is a real address, and otherwise
the instance's own https base URL — which is a valid VAPID subject, points at
something that exists, and every push service including Apple accepts.

Two things found alongside it, both fixed:

- A successful push did not clear the failure count. A subscription drops at ten
  failures, and all four live ones sat at nine from this bug — one flaky push from
  being deleted despite working. MarkPushDelivered zeroes the count and moves
  last_used_at on every delivery, so a subscription that recovers is not still
  carrying its old failures toward the cliff.

- A reminder with no project made a notification whose URL was "/projekt/" — a
  dead link. Empty project now points at the app root.

Test: the subject is a real mailto when there is one, and the https URL when there
is not — including for the localhost default, which is not a real address.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 08:24:20 +02:00
Kristian Wind c0dbea10c5 Merge pull request #25 from kristianwind/note-footer-archive
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m8s
CI / Docker image builds (push) Successful in 1m49s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
Add an Archive button to the open note's footer
2026-08-24 15:45:14 +02:00
kristianwindandClaude Opus 5 a70ca93102 feat(notes): an Archive button in the open note's footer
Archiving a single note meant finding its row in the list and its hover-only
button. With a note open to read or write, the one place you would reach for it —
its own footer, beside Gem and Slet — had no way to put it away.

There is one now: Arkivér between Save and Delete, which reads "bring it back" once
the note is already archived. Owner only, like delete, and it runs the same archive
the row button does — the note leaves the list without leaving the account.

e2e: archiving from the footer takes the note off the list, the archive holds it
with a bring-back button, and bringing it back returns it. The existing bulk-archive
test is scoped to the selection bar, now that "Arkivér" also lives in the footer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 15:37:17 +02:00
Kristian Wind 2bda3a26db Merge pull request #24 from kristianwind/inline-note-links
CI / Go (push) Failing after 13m26s
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 6m45s
CI / Docker image builds (push) Successful in 1m38s
Release / Build and publish image (push) Failing after 7s
Make [[note]] an inline clickable link, not bracketed text
2026-08-24 13:48:59 +02:00
kristianwindandClaude Opus 5 8022502015 feat(notes): [[note]] is an inline link, not bracketed text
Choosing a note from the [[ completion wrote [[Title]] as plain text. It made a
real link behind the scenes — it showed in the panel under the note — but in the
note itself it stayed brackets, and "it doesn't become a link" is exactly how that
reads. A wiki link is a link where it is written.

Now a [[note]] renders inline as the title, in the accent colour, and a click on it
opens the note it points at. It is atomic — contenteditable="false" — so it moves
and deletes as one thing and a click is a click, not a caret landing mid-word. And
it appears the moment it is chosen: the completion drops the finished link in,
brackets and all swallowed, rather than text waiting to become a link when the note
is next opened.

The seam holds both ways: markdownToHtml lifts [[title]] into the link the same way
it already lifts an image, and htmlToMarkdown reads the link back to [[title]] by
the title it carries — so what is on disk is still the same Markdown, and a note
opened, saved and reopened is unchanged.

e2e: choosing a note writes an inline link, it survives the round-trip through
Markdown and a reload, and clicking it opens the note it names. Full suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 13:41:04 +02:00
Kristian Wind b07938e351 Merge pull request #23 from kristianwind/clickable-note-links
CI / Go (push) Failing after 14m28s
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m18s
CI / Docker image builds (push) Successful in 1m29s
Release / Build and publish image (push) Failing after 6s
Fix: a [[note]] link is one you can actually follow
2026-08-24 13:19:40 +02:00
kristianwindandClaude Opus 5 de95df44c1 fix(notes): a [[note]] link is one you can follow
Picking a note from the [[ completion wrote [[Title]] and the server made a real
link from it — but the link only ever showed in the panel under the note as plain
text. It said the note pointed somewhere and gave no way to go there, which reads
as "it didn't become a link at all".

The links in that panel are buttons now. A [[note]] resolves by its title to the
note — the one already on the list, or looked up if not — and opens it in place; a
#project goes to its page; a task link to the task. Titles with punctuation work,
brackets and parentheses included, because the match is on the whole title, not a
parse of it.

e2e: after linking a note, clicking its chip opens the note it names.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 13:11:46 +02:00
Kristian Wind 2d7b6a1d5e Merge pull request #22 from kristianwind/fix-notes-export-order
CI / OpenAPI is valid (push) Successful in 10s
CI / Go (push) Failing after 13m11s
CI / Docker image builds (push) Successful in 1m40s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 7m10s
Fix: export notes in a stable order (flaky export test)
2026-08-24 12:44:36 +02:00
kristianwindandClaude Opus 5 4bbc49fe7e fix(notes): export the notes in a stable order
AllNotes returns newest-first by the second, so two notes written in the same
second have no order between them — and the notes export walks that list assigning
filenames, giving the first note of a name the plain filename and the next the
"(2)". With the order undecided, which of two same-titled notes got which name was
a coin toss that landed differently on each export, and the export test failed
roughly one run in three.

Sort the export by when each note was made, with the id to break a same-second
tie. The first note of a name keeps the name; the archive is the same twice
running. Ids are time-ordered, so the tie-break agrees with creation order rather
than fighting it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 12:39:11 +02:00
Kristian Wind db2bf619ce Merge pull request #21 from kristianwind/note-link-autocomplete
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m42s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 13m24s
Fix: [[ completes a note link, like # completes a project
2026-08-24 12:30:04 +02:00
kristianwindandClaude Opus 5 6116ed2e21 fix(notes): [[ completes a note link, the way # completes a project
Typing [[ did nothing. The toolbar hint offered "[[ another note" and the parser
turned a fully-typed [[Title]] into a link on save, but the completion that makes
it usable — the one # has for projects — was never there, so you had to know and
type a note's title exactly, brackets and all.

Now [[ opens the same picker # does, over the notes the page has loaded: the
current note is left out (a note linking to itself is a loop nobody meant), and
one without a title yet cannot be pointed at. Choosing a note closes its own
brackets — the opener is already in the text — and leaves a space, so the next
word is a word and not more of the tag. On save the link is real and shows in the
note's links, not merely as text.

The picker's two kinds are told apart where a project wears its colour: a note
suggestion wears the brackets that open it.

e2e: [[ offers a note by title, choosing it writes [[Title]] into the note, and
the link lands in the note's links. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 12:22:38 +02:00
Kristian Wind 1ba26ee723 Merge pull request #20 from kristianwind/site-features-light-shots
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m50s
CI / Docker image builds (push) Successful in 1m44s
CI / Go (push) Failing after 14m5s
Release / Build and publish image (push) Failing after 6s
Website: new features and light-theme screenshots
2026-08-24 10:37:07 +02:00
kristianwindandClaude Opus 5 fcedab0b19 docs(site): add the new features and light screenshots of them
The landing page did not mention two things the app grew — sharing a note with a
person, and task durations — and its screenshots were all in the dark theme.

- Copy: a cell for sharing a note or a page (to a person, or by filing it in a
  project, with the Shared with me group), and one for how long a task takes (drawn
  to length on the calendar, dragged by its foot, kept when moved).

- Two new screenshots in the light theme, at the top of the gallery: the calendar
  drawing timed tasks to their real length, and a note somebody shared gathered
  under Shared with me. English throughout, like the rest of the site — the capture
  account is English, so the interface is.

The generator grew the demo data these need: a handful of timed tasks with lengths
across the week, and a note the colleague shares back to the owner. Every other
screenshot was recaptured from the current build in the same pass, so the whole set
reflects what ships today (the tidied note footer, and the rest).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 10:30:00 +02:00
Kristian Wind 72b6ea3803 Merge pull request #19 from kristianwind/task-durations-calendar
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m58s
CI / Docker image builds (push) Successful in 1m40s
CI / Go (push) Failing after 13m55s
Task durations: draw, resize, and edit a task's length
2026-08-24 10:22:37 +02:00
kristianwindandClaude Opus 5 4f25905d81 feat(calendar): a task's length is drawn, resizable, and editable
A timed task was drawn at a fixed half hour whatever its `duration_min` said, so a
length the data model already carried was invisible and uneditable. Three parts:

- The calendar draws a task at its real length — a quarter-hour floor so a
  zero-minute task is still grabbable, the day's end as the ceiling — instead of a
  flat thirty minutes. A ninety-minute task now looks like ninety minutes.

- A grip along the foot of a timed task drags its length. Pointer events, not the
  HTML5 drag the body uses, and stopPropagation between them — otherwise making a
  task longer would pick it up and move it instead. It snaps to the quarter hour,
  follows the handle live, and writes once on release through a new store `resize`.
  Moving a task (the existing day/time drag) leaves the length alone.

- The task drawer gains a length field beside the time — minutes, shown only with
  a time to hang it on, since a duration without a start is a length of nothing and
  the calendar has nowhere to draw it. So a length can be set off the calendar too.

e2e: dragging the foot of a task makes it longer both on screen and in what the
server stores, and the time does not move. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 10:14:39 +02:00
Kristian Wind e3eeb3037c Merge pull request #18 from kristianwind/tidy-note-share-footer
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m51s
CI / Docker image builds (push) Successful in 1m37s
CI / Go (push) Failing after 14m7s
Tidy the note sharing UI: gather it behind a Del button
2026-08-24 10:05:08 +02:00
kristianwindandClaude Opus 5 97e6be3afa fix(notes): gather sharing behind a Del button so the footer stays tidy
The sharing controls were laid out across the note's footer — the project select,
the whole "Del med personer" panel and the Save/Delete buttons all on one row. It
crammed: "Læg i projekt" wrapped to two lines and the people panel ran into the
rest.

Now one Del button in the footer opens a popover that holds both ways a note is
shared — a project and named people — stacked in a small card. The footer is a
status on the left and Del / Gem / Slet on the right, and nothing wraps. The
button carries a count when the note is shared with someone, so "shared" is
legible without opening it.

The popover closes on a click outside, on Escape, and on switching to another
note — the last tracked by note id, so a save returning a fresh copy of the same
note does not slam it shut mid-use. The inner "add someone" button is now Tilføj,
so it does not read as a second Del.

e2e updated to open the popover before reaching the controls; full Chromium suite
green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 09:58:02 +02:00
49 changed files with 908 additions and 159 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+12
View File
@@ -313,6 +313,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
return
}
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
// the second, so two notes made in the same second have no order between them —
// and then which of two same-titled notes gets the plain name and which gets
// "(2)" is a coin toss that lands differently each export. Ordering the whole
// thing by when it was made settles it: the first note of a name keeps the name.
sort.SliceStable(notes, func(i, j int) bool {
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
return notes[i].ID < notes[j].ID
}
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
})
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
+33 -2
View File
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
return out
}
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
target := "/projekt/" + projectID
if projectID == "" {
target = "/"
}
payload := push.Payload{Title: title, Body: body, URL: target}
subject := s.vapidSubject()
for _, sub := range subs {
err := push.Send(ctx, push.Subscription{
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
}, payload, push.VAPID{
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
Public: public, Private: private, Subject: subject,
})
if err == nil {
out.Sent++
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
s.log.Warn("mark push delivered", "err", delErr)
}
continue
}
// Warn, not Debug.
@@ -257,6 +265,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
}
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
//
// It has to be one the service will actually accept, and Apple is the strict one:
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
// what the old fallback, mailto:[email protected], was on an instance with
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
// the person waiting for the notification could tell.
//
// So a real address is used when there is one, and otherwise the instance's own
// https address — which is a valid VAPID subject, points at something that exists,
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
// the https URL a 201.
func (s *Server) vapidSubject() string {
from := strings.TrimSpace(s.cfg.SMTP.From)
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
return "mailto:" + from
}
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
return s.cfg.BaseURL
}
return "mailto:[email protected]"
}
// --- subscription endpoints -----------------------------------------------------------
type pushSubscriptionRequest struct {
+33
View File
@@ -0,0 +1,33 @@
package httpapi
import (
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
// every push to an Apple device was refused. With no real address, the instance's
// own https URL is used instead — valid, real, and accepted.
func TestVAPIDSubject(t *testing.T) {
cases := []struct {
name string
from string
baseURL string
want string
}{
{"a real address is used as-is", "[email protected]", "https://verdande.example.dk", "mailto:[email protected]"},
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
if got := s.vapidSubject(); got != tc.want {
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
}
})
}
}
+38 -13
View File
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
for _, t := range cal.Tasks {
writeTodo(&b, t, cal.Domain)
// A dated task also goes out as an event, for clients that ignore VTODO.
if t.DueDatetime != nil && !t.Completed {
// A dated task also goes out as an event, for the calendar apps that ignore
// VTODO — which is all of them: Apple Calendar and Google both drop a
// subscribed to-do on the floor and show only events. A task with a clock
// time becomes a timed event; one with only a date, an all-day event. Once
// this was limited to timed tasks, so a date-only task — most of them —
// simply never appeared in the calendar it was subscribed into.
if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") {
writeEvent(&b, t, cal.Domain)
}
}
@@ -128,22 +133,31 @@ func writeTodo(b *strings.Builder, t Task, domain string) {
}
func writeEvent(b *strings.Builder, t Task, domain string) {
start := *t.DueDatetime
// Without a stated duration a task is shown as half an hour. A zero-length
// event collapses to an invisible sliver in most calendar grids.
minutes := 30
if t.DurationMin != nil && *t.DurationMin > 0 {
minutes = *t.DurationMin
}
end := start.Add(time.Duration(minutes) * time.Minute)
b.WriteString("BEGIN:VEVENT\r\n")
// A distinct UID suffix: the event and the to-do describe the same task, and
// sharing a UID would make a client treat them as two versions of one item.
writeLine(b, "UID:"+t.ID+"-event@"+domain)
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
writeLine(b, "DTSTART:"+utc(start))
writeLine(b, "DTEND:"+utc(end))
if t.DueDatetime != nil {
start := *t.DueDatetime
// Without a stated duration a task is shown as half an hour. A zero-length
// event collapses to an invisible sliver in most calendar grids.
minutes := 30
if t.DurationMin != nil && *t.DurationMin > 0 {
minutes = *t.DurationMin
}
writeLine(b, "DTSTART:"+utc(start))
writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute)))
} else {
// A date with no time is an all-day event. DTEND is exclusive — a one-day
// event ends on the following date — which is what keeps it a single day in
// the grid rather than bleeding into the next.
day := strings.ReplaceAll(t.DueDate, "-", "")
writeLine(b, "DTSTART;VALUE=DATE:"+day)
writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate))
}
writeLine(b, "SUMMARY:"+escape(t.Content))
if t.Description != "" {
writeLine(b, "DESCRIPTION:"+escape(t.Description))
@@ -157,6 +171,17 @@ func writeEvent(b *strings.Builder, t Task, domain string) {
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive
// DTEND. A malformed date is returned as its own compacted form rather than
// dropped, so a task never loses its event over a bad string.
func nextDay(date string) string {
d, err := time.Parse("2006-01-02", date)
if err != nil {
return strings.ReplaceAll(date, "-", "")
}
return d.AddDate(0, 0, 1).Format("20060102")
}
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
// a value separator in iCalendar, so an unescaped one silently truncates the
// summary at that character in every client.
+18 -9
View File
@@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) {
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
// or a feed is useless in the calendar most people actually keep.
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
func TestDatedTasksBecomeEvents(t *testing.T) {
out := Render(sample())
if strings.Count(out, "BEGIN:VEVENT") != 1 {
t.Fatalf("got %d VEVENTs, want one for the single timed task",
strings.Count(out, "BEGIN:VEVENT"))
// Every dated, unfinished task becomes an event, so it shows in a calendar app
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
// date and no event.
if got := strings.Count(out, "BEGIN:VEVENT"); got != 3 {
t.Fatalf("got %d VEVENTs, want 3 (one timed, two all-day)", got)
}
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
t.Error("the event has no start")
// The timed one keeps its clock and its 45-minute duration.
if !strings.Contains(out, "DTSTART:20260310T090000Z") || !strings.Contains(out, "DTEND:20260310T094500Z") {
t.Error("the timed event's start or duration is wrong")
}
// 45 minutes of stated duration.
if !strings.Contains(out, "DTEND:20260310T094500Z") {
t.Error("the event's end does not reflect the task's duration")
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
// they sit on their own day rather than bleeding into the next.
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260312") || !strings.Contains(out, "DTEND;VALUE=DATE:20260313") {
t.Error("the date-only event is not a single all-day event")
}
// A recurring date-only task carries its rule onto the event.
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
t.Error("the recurring task's event has no RRULE")
}
// The event must not share the to-do's UID, or clients treat them as one item.
if !strings.Contains(out, "UID:[email protected]") {
+11
View File
@@ -204,6 +204,17 @@ func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
// but it also has bad days — forgiving a few failures keeps a flaky network from
// silently unsubscribing somebody's phone.
// MarkPushDelivered records that a push went through: the failure count goes back
// to zero and last_used_at moves forward. Without it a subscription that failed a
// few times and then recovered would keep those failures on its record and be one
// flaky night away from the ten that delete it — even though it is working.
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
_, err := db.ExecContext(ctx,
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
time.Now().Unix(), endpoint)
return err
}
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
if permanent {
// Not scoped by user, and that is right here: this is the push service
+37
View File
@@ -926,6 +926,26 @@
</figure>
<div class="shots">
<figure class="shot">
<img
src="screenshots/calendar-light.webp"
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/notes-shared-light.webp"
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/board.webp"
@@ -1063,6 +1083,23 @@
written about it. Archive what is done with it; the bin is for mistakes.
</p>
</div>
<div class="cell">
<h3>Share a note, or a page</h3>
<p>
Hand a single note to a person — to read or to edit — without standing up
a project for it. Notes other people share with you gather under
<em>Shared with me</em>, each marked with whose it is. File it in a project
instead and everyone who can read the project reads the note.
</p>
</div>
<div class="cell">
<h3>How long it takes</h3>
<p>
A task can carry a length, and the calendar draws it to scale. Drag the
foot of a task to make it longer or shorter, or drag its body to another
day or time — the length comes along.
</p>
</div>
<div class="cell">
<h3>Waiting on others</h3>
<p>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+143 -5
View File
@@ -1225,6 +1225,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
* thing this view was before anything was laid over it — and a Google account that
* is not there must not take it down with it.
*/
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// A timed task today at ten, half an hour long. Today, so the week opens on it
// without paging, and ten is inside the hours the grid draws by default.
const today = await page.evaluate(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const id = await page.evaluate(async (day) => {
const r = await fetch('/api/v1/tasks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
content: 'Møde med Anders',
due_date: day,
due_time: '10:00',
duration_min: 30
})
});
return (await r.json()).id;
}, today);
await page.goto('/kalender');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
const chip = page
.locator(`[data-date="${today}"] .tevent.task`)
.filter({ hasText: 'Møde med Anders' });
await expect(chip).toBeVisible();
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
// Drag the foot of the task down a good stretch. It should grow, both on screen
// and in what the server stores — a resize is a change of length, not of time.
const box = await chip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
await page.mouse.up();
await expect
.poll(async () =>
page.evaluate(async (tid) => {
const r = await fetch('/api/v1/tasks/' + tid, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).duration_min;
}, id)
)
.toBeGreaterThan(30);
// And the chip is taller than it was, so the length is something you can see.
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
// The time did not move: it is still a ten o'clock task.
await expect(chip.locator('.at')).toHaveText('10:00');
expect(trouble).toEqual([]);
});
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/kalender');
@@ -2440,7 +2503,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
await page.keyboard.type('Fælles aftale om leveringen');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Shared by being filed in a project. Not an access list of its own.
// Filing it in a project — the controls live in the Del popover in the footer.
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
await expect(page.getByText('delt med projektets folk')).toBeVisible();
@@ -2451,6 +2515,7 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
// Taken back out, it is the author's alone again.
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: /Fælles aftale/ }).click();
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
await expect(page.getByText('din igen')).toBeVisible();
@@ -2487,10 +2552,12 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
await page.keyboard.type('Ferieplan for hele holdet');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
const share = page.locator('.people-share');
// Sharing lives in a popover behind the Del button in the footer.
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
await page.getByRole('button', { name: 'Del', exact: true }).click();
await share.getByRole('button', { name: 'Tilføj' }).click();
// She is on the note now, with the role she was given.
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
@@ -2557,6 +2624,47 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
expect(trouble).toEqual([]);
});
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// One note to point at, then another to write the link in.
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Rejseplan til Berlin');
await ed.blur();
await page.waitForTimeout(600);
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await page.keyboard.type('Mine planer');
await page.keyboard.press('Enter');
await page.keyboard.type('Se ');
await page.keyboard.type('[[Rejse');
// The picker offers the note by title, and choosing it turns the text into a
// real inline link — the title, not the brackets.
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
await expect(option).toBeVisible();
await option.click();
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
await expect(inline).toBeVisible();
// It survives the round-trip through Markdown and a reload.
await ed.blur();
await page.waitForTimeout(900);
await page.reload();
await page.getByRole('button', { name: /Mine planer/ }).click();
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
// And it is a link you can follow: clicking it opens the note it names.
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
expect(trouble).toEqual([]);
});
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
@@ -2981,7 +3089,8 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
await page.waitForTimeout(1200);
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
// uden at nogen har valgt det bagefter.
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
await page.locator('.sharewrap > button').click();
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
expect(shared).toContain('Tagprojekt');
@@ -3049,6 +3158,34 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
expect(trouble).toEqual([]);
});
test('en note kan arkiveres fra sin egen fod, og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Kvartalsplan');
await ed.blur();
await page.waitForTimeout(700);
// The Arkivér button in the note's footer puts it away: it leaves the list.
await page.locator('footer .actions .button', { hasText: 'Arkivér' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
// It is in the archive, where the same button now offers to bring it back.
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await page.locator('.notes button.row', { hasText: 'Kvartalsplan' }).click();
await page.locator('footer .actions .button', { hasText: 'Tag frem igen' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
// And back in the ordinary list.
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
@@ -3184,7 +3321,8 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
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();
// The selection bar's Arkivér, not the open note's own in the footer.
await page.locator('.picked-bar').getByRole('button', { name: 'Arkivér', exact: true }).click();
await page.waitForTimeout(900);
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
+43
View File
@@ -73,6 +73,11 @@ const SHOTS = [
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
// The new work, and in the light theme, which is what most people want to look
// at a landing page in. The calendar drawing tasks to their length, and a note
// somebody shared into the "Shared with me" group.
{ name: 'calendar-light', path: '/kalender', theme: 'light', prepare: showWeek },
{ name: 'notes-shared-light', path: '/noter', theme: 'light', prepare: openSharedNote },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
// Scrolled, because the themes are the second panel on the page and a picture
@@ -114,6 +119,13 @@ async function showNoteSource(page) {
await page.waitForTimeout(600);
}
async function openSharedNote(page) {
// The note Mette shared in, so the "Shared with me" group and its owner chip are
// what the picture is of rather than something the group happens to sit above.
await page.locator('button.row', { hasText: 'Packaging quotes' }).first().click();
await page.waitForTimeout(600);
}
async function showThemes(page) {
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
// already a few pixels inside the viewport when the page loads, so "if needed"
@@ -340,6 +352,23 @@ async function handOver(ids) {
await api(`/tasks/${ids.detail}/comments`, {
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
});
// A note of Mette's own, handed to the owner — so their Notes page has a
// "Shared with me" group with a real note in it, marked as hers. This is the
// other half of sharing: not the project path, but a page passed to a person.
const note = await api('/notes', {
body: `# Packaging quotes
Three back, one still to come:
- **Nordfelt** — 79 øre a bag at 2,000, plates included
- **Kolding Karton** — 84 øre, faster, kinder about small runs
- Bruun still owes me a number
I'd lean Kolding for the first run and revisit at volume. #Autumn launch`
});
await api(`/notes/${note.id}/shares`, { user_id: ids.owner, role: 'editor' });
return { colleague: me.id };
}
@@ -373,6 +402,8 @@ async function seed({ colleague, noteTitle }) {
).padStart(2, '0')}`;
};
const me = await api('/auth/me', null, 'GET');
// Two headings over the projects, which is what the sidebar grew for.
const work = await api('/project-groups', { name: 'Work' });
const home = await api('/project-groups', { name: 'Home' });
@@ -420,6 +451,17 @@ async function seed({ colleague, noteTitle }) {
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
// A handful given a time and a length, so the calendar's week draws them to
// scale rather than as a row of identical blocks — a stand-up, a long
// planning block, a call, a review. Spread across the day and the days, so
// the picture is a week that has a shape.
{ content: 'Stand-up', due_date: day(0), due_time: '09:00', duration_min: 15, project_id: launch.id, section_id: inFlight.id },
{ content: 'Planning block', due_date: day(0), due_time: '10:00', duration_min: 90, priority: 2, project_id: launch.id, section_id: planning.id, labels: ['meeting'] },
{ content: 'Call with the printer', due_date: day(0), due_time: '13:30', duration_min: 30, project_id: launch.id, labels: ['waiting'] },
{ content: 'Interview: designer', due_date: day(1), due_time: '11:00', duration_min: 60, priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'Budget review', due_date: day(2), due_time: '14:00', duration_min: 45, priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'One-to-one with Mette', due_date: day(3), due_time: '15:30', duration_min: 30, project_id: launch.id },
// The next couple of weeks, so Upcoming and the month grid have something in
// them rather than a row of empty cells.
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
@@ -560,6 +602,7 @@ The opening notes are a task, so they can be ticked off: task:${detail.id}`
await api('/users', { email: '[email protected]' });
return {
owner: me.id,
work: launch.id,
detail: detail.id,
invite: invite.link,
+88 -3
View File
@@ -286,6 +286,48 @@
/** The cell lit up under the pointer. */
let over = $state(null);
// A resize in progress: which task, and where its foot is being dragged to, in
// minutes since midnight. Kept apart from the stored length so the task can
// follow the handle live and only be written when the drag ends.
let resizing = $state(null);
/**
* Dragging the foot of a timed task changes how long it is.
*
* Pointer events rather than the HTML5 drag the body uses: the two must not be
* the same gesture, or every attempt to make a task longer would instead pick it
* up and move it. stopPropagation keeps the handle's press from starting the
* move, and the column it lives in is what the minutes are measured against —
* the same box onDropAt reads a drop time from.
*/
function startResize(event, task, date) {
event.stopPropagation();
event.preventDefault();
const col = event.currentTarget.closest('.daycol');
if (!col || !task.due_datetime) return;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) };
const onMove = (e) => {
const box = col.getBoundingClientRect();
const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1);
const minutes = hours.from * 60 + share * hours.minutes;
// Quarter-hour steps, at least a quarter long, never past midnight.
const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60);
resizing = { ...resizing, to: snapped };
};
const onUp = async () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
const current = resizing;
resizing = null;
if (current) await app.resize(current.id, current.to - current.from);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
function onDragOver(event, date) {
if (!carries(event, TASK)) return;
accept(event);
@@ -394,7 +436,16 @@
if (!task.due_datetime) continue;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
out.push({ kind: 'task', ref: task, from, to: from + 30 });
// The task's own length, not a fixed half hour. A quarter-hour floor so a
// zero-minute task is still something to see and grab, and the end of the
// day is the ceiling. While a resize is in flight this task follows the
// handle instead of its stored length.
let to =
resizing && resizing.id === task.id
? resizing.to
: from + (task.duration_min || 30);
to = Math.min(Math.max(to, from + 15), 24 * 60);
out.push({ kind: 'task', ref: task, from, to });
}
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
@@ -547,12 +598,13 @@
</svelte:element>
{/snippet}
{#snippet taskChip(task, kind, extra = '', at = '')}
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
<!-- Draggable as well as clickable: moving something to another day is the
whole reason to be looking at a calendar. The browser needs a few pixels
of movement before it calls a press a drag, so the click survives. -->
<button
class={kind}
class:resizing={resizing?.id === task.id}
data-priority={task.priority}
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
@@ -562,6 +614,16 @@
>
{#if at}<span class="at">{at}</span>{/if}
{task.content}
<!-- Only a task standing at a time can be made longer, and only where the
grid has room to show it — the timed column, which passes the date. -->
{#if date && task.due_datetime}
<span
class="resize"
title={t('cal.resize')}
aria-hidden="true"
onpointerdown={(e) => startResize(e, task, date)}
></span>
{/if}
</button>
{/snippet}
@@ -632,7 +694,7 @@
{#if item.kind === 'event'}
{@render eventChip(item.ref, date, 'tevent', place(item))}
{:else}
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)}
{/if}
{/each}
</div>
@@ -937,6 +999,29 @@
cursor: grab;
}
/* The grip along the foot of a task, for dragging its length. Out of the way
until the task is hovered or being resized, so it never competes with the
words above it. */
.tevent.task .resize {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 7px;
cursor: ns-resize;
opacity: 0;
touch-action: none;
}
.tevent.task:hover .resize,
.tevent.task.resizing .resize {
opacity: 1;
background: linear-gradient(to bottom, transparent, var(--line-strong));
}
.tevent.task.resizing {
cursor: ns-resize;
z-index: 5;
}
.tevent.task[data-priority='1'] {
border-left-color: var(--p1);
}
+128 -41
View File
@@ -20,7 +20,7 @@
import { api, humanMessage } from '$lib/api.js';
import { colorVar } from '$lib/colors.js';
let { note, onchange, onsave } = $props();
let { note, notes = [], onchange, onsave, onopennote } = $props();
let editor;
// Forslagslisten, så tastaturvalget kan rulle den frem.
@@ -390,9 +390,26 @@
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
const before = node.textContent.slice(0, selection.anchorOffset);
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
if (!match) return null;
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
// A note link, [[, is the same idea as a project tag and gets the same
// completion — checked first because it is the longer opener. The term runs
// to the caret and is anything up to the closing bracket, which has not been
// typed yet.
const note = /\[\[([^\]\n]*)$/u.exec(before);
if (note) {
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
}
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
if (project) {
return {
kind: 'project',
node,
start: selection.anchorOffset - project[1].length,
term: project[1]
};
}
return null;
}
/**
@@ -436,46 +453,87 @@
// pixels for hvert bogstav, mens listen snævres ind.
if (!suggestions.length) menuAt = tagPosition(partial);
// Alle der passer, ikke de seks første.
//
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
// noget — kun at ens eget projekt ikke var der.
//
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
const matches = app.projects.filter(
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
);
matches.sort((a, b) => {
const an = a.name.toLowerCase();
const bn = b.name.toLowerCase();
const ap = an.startsWith(term);
const bp = bn.startsWith(term);
if (ap !== bp) return ap ? -1 : 1;
return an.localeCompare(bn, 'da');
});
suggestions = matches;
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
chosen = 0;
}
/** Replaces the half-typed tag with the whole name. */
function accept(project) {
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: det, der begynder
// med det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
function projectMatches(term) {
return app.projects
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
.map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color }))
.sort((a, b) => rank(a.label, b.label, term));
}
// The same completion for [[ as for #, over the notes the page has loaded. The
// current note is left out — a note that links to itself is a loop nobody meant
// — and a note with no title yet cannot be pointed at, so it is skipped too.
function noteMatches(term) {
return notes
.filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term))
.map((n) => ({ id: n.id, kind: 'note', label: n.title }))
.sort((a, b) => rank(a.label, b.label, term));
}
function rank(a, b, term) {
const al = a.toLowerCase();
const bl = b.toLowerCase();
const ap = al.startsWith(term);
const bp = bl.startsWith(term);
if (ap !== bp) return ap ? -1 : 1;
return al.localeCompare(bl, 'da');
}
const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escAttr = (s) => escHtml(s).replace(/"/g, '&quot;');
// A click on a note link follows it; a click anywhere else re-colours the code,
// as it did before. The link is contenteditable="false", so the click is a click
// and not a caret landing in the middle of a word.
function onEditorClick(event) {
const link = event.target.closest?.('.notelink');
if (link) {
event.preventDefault();
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
return;
}
colourCode();
}
/** Replaces the half-typed tag with the whole thing. */
function accept(item) {
const partial = partialTag();
if (!partial) return;
const range = document.createRange();
range.setStart(partial.node, partial.start);
range.setEnd(partial.node, partial.start + partial.term.length);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
// A trailing space, because the next thing typed is a word and not more of
// the tag — and without it the suggestions come straight back.
document.execCommand('insertText', false, project.name + ' ');
if (item.kind === 'note') {
// Swallow the opening [[ as well and drop the finished link in its place,
// so it is a link the moment it is chosen — not the brackets, waiting to
// become one when the note is next opened. A space after, as its own text,
// so the caret leaves the atomic link and the next word is a word.
range.setStart(partial.node, partial.start - 2);
range.setEnd(partial.node, partial.start + partial.term.length);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand(
'insertHTML',
false,
`<a class="notelink" data-note="${escAttr(item.label)}" contenteditable="false">${escHtml(item.label)}</a>`
);
document.execCommand('insertText', false, ' ');
} else {
range.setStart(partial.node, partial.start);
range.setEnd(partial.node, partial.start + partial.term.length);
selection.removeAllRanges();
selection.addRange(range);
// A trailing space, or the suggestions come straight back for the tag.
document.execCommand('insertText', false, `${item.label} `);
}
suggestions = [];
onchange?.(htmlToMarkdown(editor));
}
@@ -776,7 +834,7 @@
readState();
readSuggestions();
}}
onclick={colourCode}
onclick={onEditorClick}
onmouseup={readState}
{onkeydown}
{onpaste}
@@ -796,7 +854,7 @@
style:top={menuAt ? `${menuAt.top}px` : null}
style:left={menuAt ? `${menuAt.left}px` : null}
>
{#each suggestions as project, i (project.id)}
{#each suggestions as item, i (item.kind + item.id)}
<li>
<button
class:on={i === chosen}
@@ -804,12 +862,17 @@
aria-selected={i === chosen}
onmousedown={(e) => {
e.preventDefault();
accept(project);
accept(item);
}}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
></span>
#{project.name}
{#if item.kind === 'note'}
<span class="dot note" aria-hidden="true">[[</span>
{item.label}
{:else}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
#{item.label}
{/if}
</button>
</li>
{/each}
@@ -962,6 +1025,18 @@
font-size: var(--text-sm);
}
/* A note suggestion wears the brackets that open it where a project wears its
colour, so the two kinds are told apart at a glance in the same list. */
.suggestions .dot.note {
width: auto;
height: auto;
background: none;
color: var(--ink-faint);
font-family: var(--mono, ui-monospace, monospace);
font-size: 0.85em;
line-height: 1;
}
.suggestions button.on {
background: var(--surface);
color: var(--ink);
@@ -1121,6 +1196,18 @@
margin: var(--s2) 0;
}
/* A note link reads as a link: the accent colour, underlined on hover, and a
pointer so it is plainly something to click rather than to edit. */
.page :global(a.notelink) {
color: var(--accent);
text-decoration: none;
cursor: pointer;
border-radius: var(--radius-sm);
}
.page :global(a.notelink:hover) {
text-decoration: underline;
}
.page :global(blockquote) {
margin: 0 0 var(--s2);
padding-left: var(--s3);
+24
View File
@@ -23,6 +23,9 @@
let priority = $state(4);
let dueDate = $state('');
let dueTime = $state('');
// Length in minutes, as a string for the field. Only meaningful with a time —
// see the field below — so it is seeded and shown alongside the clock.
let duration = $state('');
let labels = $state('');
let projectId = $state('');
let recurrence = $state('');
@@ -57,6 +60,7 @@
priority = task.priority;
dueDate = task.due_date ?? '';
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
duration = task.duration_min ? String(task.duration_min) : '';
labels = (task.labels ?? []).join(', ');
projectId = task.project_id;
recurrence = task.recurrence_rule ?? '';
@@ -436,6 +440,21 @@
disabled={!dueDate}
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
/>
<!-- Length in minutes, and only with a time to hang it on: a duration
without a start is a length of nothing, and the calendar has
nowhere to draw it. Empty clears it. -->
<input
type="number"
class="dur"
min="0"
step="15"
aria-label={t('detail.duration')}
placeholder={t('detail.durationShort')}
bind:value={duration}
disabled={!dueTime}
onchange={() =>
save({ duration_min: duration === '' ? 0 : Math.max(0, Math.round(Number(duration))) })}
/>
</div>
</div>
</div>
@@ -731,6 +750,11 @@
width: 7.5rem;
}
.due input.dur {
flex: none;
width: 5rem;
}
.due input:disabled {
opacity: 0.5;
}
+7 -1
View File
@@ -22,10 +22,13 @@ export const da = {
'notes.unshared': 'Noten er din igen.',
// Deling med enkeltpersoner, ved siden af projektet.
'notes.groupSharedWithMe': 'Delt med mig',
'notes.linkOpen': 'Åbn',
'notes.linkMissing': 'Noten blev ikke fundet.',
'notes.sharedBy': 'Delt af {name}',
'notes.sharePeople': 'Del med personer',
'notes.sharePick': 'Vælg en person …',
'notes.shareAdd': 'Del',
'notes.shareButton': 'Del',
'notes.shareAdd': 'Tilføj',
'notes.shareRoleViewer': 'Kan læse',
'notes.shareRoleEditor': 'Kan rette',
'notes.shareRemove': 'Fjern adgang',
@@ -596,6 +599,8 @@ export const da = {
'detail.none': 'Ingen',
'detail.due': 'Forfalder',
'detail.dueTime': 'Klokkeslæt',
'detail.duration': 'Længde i minutter',
'detail.durationShort': 'min',
'detail.labels': 'Etiketter',
'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt',
@@ -748,6 +753,7 @@ export const da = {
'cal.settings': 'Indstillinger',
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
'cal.allDay': 'Hele dagen',
'cal.resize': 'Træk for at ændre længden',
'cal.openInGoogle': 'Åbn i Google Kalender',
'cal.beyondWindow':
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
+7 -1
View File
@@ -18,10 +18,13 @@ export const en = {
'notes.unshared': 'The note is yours again.',
// Sharing with individual people, alongside the project.
'notes.groupSharedWithMe': 'Shared with me',
'notes.linkOpen': 'Open',
'notes.linkMissing': 'That note could not be found.',
'notes.sharedBy': 'Shared by {name}',
'notes.sharePeople': 'Share with people',
'notes.sharePick': 'Choose someone …',
'notes.shareAdd': 'Share',
'notes.shareButton': 'Share',
'notes.shareAdd': 'Add',
'notes.shareRoleViewer': 'Can view',
'notes.shareRoleEditor': 'Can edit',
'notes.shareRemove': 'Remove access',
@@ -592,6 +595,8 @@ export const en = {
'detail.none': 'None',
'detail.due': 'Due',
'detail.dueTime': 'Time',
'detail.duration': 'Length in minutes',
'detail.durationShort': 'min',
'detail.labels': 'Labels',
'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project',
@@ -741,6 +746,7 @@ export const en = {
'cal.settings': 'Settings',
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
'cal.allDay': 'All day',
'cal.resize': 'Drag to change the length',
'cal.openInGoogle': 'Open in Google Calendar',
'cal.beyondWindow':
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
+33 -6
View File
@@ -70,19 +70,39 @@ const attr = (s) => escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
* det overlever hverken en contenteditable eller en Markdown-fil.
*/
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
// [[a note by title]], the spelling every note app settled on. Non-greedy, so two
// links on one line stay two.
const NOTELINK = /\[\[([^\]\n]+?)\]\]/g;
const MARK = "\u0000";
/** Inline marks, innermost first so a bold word inside italics survives both. */
function inlineToHtml(text) {
// Lifted out before anything escapes or marks the text, and dropped back in
// whole at the end — the same placeholder trick the image already used, now for
// two kinds of thing the surrounding markup must not touch.
const pieces = [];
const lift = (html) => {
pieces.push(html);
return `${MARK}${pieces.length - 1}${MARK}`;
};
// Kun vores egen adresse bliver til et billede. Et `![](http://…)` fra en
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
// have en sporingspixel. Alt andet står som den tekst, det er.
const images = [];
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
images.push(`<img src="${src}" alt="${attr(alt)}">`);
return `${MARK}${images.length - 1}${MARK}`;
});
let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`<img src="${src}" alt="${attr(alt)}">`));
// A [[note]] is a link you can follow, not the brackets it was typed as. Made an
// atomic token — contenteditable="false" — so it moves and deletes as one thing,
// and it carries the title it points at in data-note; the editor reads that on a
// click. htmlToMarkdown turns it back into [[title]] exactly.
lifted = lifted.replace(
NOTELINK,
(_, title) =>
lift(
`<a class="notelink" data-note="${attr(title)}" contenteditable="false">${escapeHtml(title)}</a>`
)
);
const marked = escapeHtml(lifted)
.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>')
@@ -91,7 +111,7 @@ function inlineToHtml(text) {
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => pieces[Number(i)] ?? '');
}
/**
@@ -234,6 +254,13 @@ function inlineToMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
// A note link goes back to the brackets it came from, by the title it carries
// rather than by the text it shows — the two are the same today, but the title
// is the thing that must survive the round trip.
if (node.tagName === 'A' && node.classList?.contains('notelink')) {
return `[[${node.getAttribute('data-note') ?? node.textContent}]]`;
}
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
switch (node.tagName) {
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
+13
View File
@@ -403,6 +403,19 @@ class AppState {
await this.update(id, { due_date: date, due_time: clock });
}
/**
* Sets how long a task is, in minutes. Nothing else about it moves — the date
* and time are left alone, so making a task longer on the calendar does not
* also reschedule it.
*/
async resize(id, minutes) {
const previous = this.get(id);
if (!previous) return;
const rounded = Math.max(15, Math.round(minutes / 15) * 15);
if (previous.duration_min === rounded) return;
await this.update(id, { duration_min: rounded });
}
async remove(id) {
const previous = [...this.tasks];
this.tasks = this.tasks.filter((t) => t.id !== id);
+240 -78
View File
@@ -14,6 +14,7 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -193,6 +194,26 @@
let shareBusy = $state(false);
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
// The whole of sharing — a project and named people — lives in a popover behind
// one button, so the note's footer stays a status and three buttons rather than
// a row that runs off its own width.
let shareOpen = $state(false);
let shareWrap = $state(null);
// Closing on a switch of note, and only then: the panel belongs to the note it
// was opened on. Tracked by id rather than by the object, so a save that returns
// a fresh copy of the same note does not slam the panel shut mid-use.
let sharePanelFor = $state(null);
$effect(() => {
const id = selected?.id ?? null;
if (id !== sharePanelFor) {
shareOpen = false;
sharePanelFor = id;
}
});
function onShareOutside(event) {
if (shareOpen && shareWrap && !shareWrap.contains(event.target)) shareOpen = false;
}
$effect(() => {
const id = selected?.id;
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
@@ -633,11 +654,56 @@
// What the note points at, for the panel under the editor.
let links = $derived(selected?.links ?? []);
/**
* Follows a link from the panel to the thing it names.
*
* A [[note]] resolves by its title to a note — the one on the list if it is
* there, otherwise looked up — and opens it in place. A #project and a task go
* to their own pages. Until now these were text and nothing else: the note said
* it pointed somewhere and there was no way to go.
*/
async function openLink(link) {
if (link.kind === 'note') {
const wanted = link.target_id.toLowerCase();
const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted);
if (here) {
open(here);
return;
}
try {
const found = (await api.notes({ q: link.target_id })).notes ?? [];
const hit = found.find((n) => (n.title ?? '').toLowerCase() === wanted) ?? found[0];
if (hit) open(hit);
else app.toast(t('notes.linkMissing'));
} catch (e) {
app.toast(humanMessage(e));
}
return;
}
if (link.kind === 'project') {
const project = app.projects.find(
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
);
if (project) goto(`/projekt/${project.id}`);
return;
}
if (link.kind === 'task') {
goto(`/opgave/${link.target_id}`);
}
}
</script>
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<svelte:window
onclick={onShareOutside}
onkeydown={(e) => {
if (e.key === 'Escape' && shareOpen) shareOpen = false;
}}
/>
<div class="notes">
<aside>
<div class="head">
@@ -849,6 +915,8 @@
{#if selected}
<NoteEditor
note={selected}
notes={notes}
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
onchange={(body) => {
draft = body;
typed();
@@ -861,79 +929,119 @@
{#if links.length}
<span class="links">
{#each links as link}
<span class="link">{label(link)}</span>
<button class="link" onclick={() => openLink(link)} title={t('notes.linkOpen')}>
{label(link)}
</button>
{/each}
</span>
{/if}
<!-- Filing a note in a project is one way to share it: everybody who can
read the project reads the note. Owner only, because moving a note
the owner is not in would take it away from them. -->
{#if ownsSelected}
<label class="share">
<span class="hint">{t('notes.shareWith')}</span>
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
<option value="">{t('notes.private')}</option>
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</label>
<!-- The other way: hand the note to named people, without a project
between. The owner picks who and at what role; the list below is
who can already see it. -->
<div class="people-share">
<span class="hint">{t('notes.sharePeople')}</span>
{#if shares.length}
<ul class="sharelist">
{#each shares as sh (sh.user.id)}
<li>
<span class="who">
<span class="avatar" style="--who: {sh.user.avatar_color}" aria-hidden="true"></span>
{sh.user.name}
</span>
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
</li>
{/each}
</ul>
{:else}
<span class="none">{t('notes.shareNobody')}</span>
{/if}
{#if shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
<option value="">{t('notes.sharePick')}</option>
{#each shareCandidates as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
<select bind:value={shareRole}>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button class="button" disabled={!sharePick || shareBusy} onclick={addShare}>
{t('notes.shareAdd')}
</button>
</div>
{/if}
</div>
{/if}
<div class="actions">
<!-- All of sharing behind one button. A note has two ways in — a
project and named people — and both belong to the owner; the
panel gathers them rather than lining them up across the footer. -->
{#if ownsSelected}
<div class="sharewrap" bind:this={shareWrap}>
<button
class="button"
class:on={shareOpen}
aria-expanded={shareOpen}
aria-haspopup="dialog"
onclick={() => (shareOpen = !shareOpen)}
>
{t('notes.shareButton')}{#if shares.length}<span class="count">{shares.length}</span>{/if}
</button>
{#if shareOpen}
<div class="sharepanel" role="dialog" aria-label={t('notes.shareButton')}>
<!-- Filing it in a project: everybody who can read the project
reads the note. A real <label> around the select, so it is
named for a screen reader and not only for the eye. -->
<section>
<label class="field">
<span class="phead">{t('notes.shareWith')}</span>
<select
aria-label={t('notes.shareWith')}
value={selected.project_id ?? ''}
onchange={(e) => share(e.currentTarget.value)}
>
<option value="">{t('notes.private')}</option>
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</label>
</section>
<!-- Handing it to named people, without a project between. -->
<section>
<span class="phead">{t('notes.sharePeople')}</span>
{#if shares.length}
<ul class="sharelist">
{#each shares as sh (sh.user.id)}
<li>
<span class="who">
<span
class="avatar"
style="--who: {sh.user.avatar_color}"
aria-hidden="true"
></span>
{sh.user.name}
</span>
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
</li>
{/each}
</ul>
{:else}
<span class="none">{t('notes.shareNobody')}</span>
{/if}
{#if shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
<option value="">{t('notes.sharePick')}</option>
{#each shareCandidates as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
<select bind:value={shareRole}>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="button"
disabled={!sharePick || shareBusy}
onclick={addShare}
>
{t('notes.shareAdd')}
</button>
</div>
{/if}
</section>
</div>
{/if}
</div>
{/if}
<button class="button" onclick={save}>{t('notes.save')}</button>
{#if ownsSelected}
<!-- Laid away, not thrown away: archive is the note leaving the list
without leaving the account, and it reads "bring it back" once it
already has. Owner only, like delete. -->
<button class="button" onclick={() => archiveOne(selected)}>
{selected.archived_at ? t('notes.unarchive') : t('notes.archive')}
</button>
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
{/if}
</div>
@@ -1555,18 +1663,71 @@
.link {
color: var(--ink-muted);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0 var(--s1);
font-family: var(--mono, ui-monospace, monospace);
font-size: inherit;
cursor: pointer;
}
.link:hover {
color: var(--ink);
background: var(--surface-raised);
border-color: var(--line-strong);
}
.share {
/* Sharing lives in a popover above the Del button, so the footer stays a status
and a few buttons rather than a row that runs off its own width. */
.sharewrap {
position: relative;
display: inline-flex;
}
.button.on {
background: var(--surface-raised);
}
.sharewrap .count {
margin-left: 0.4em;
padding: 0 0.45em;
border-radius: var(--radius-full);
background: var(--line-strong);
font-size: 0.85em;
}
.sharepanel {
position: absolute;
bottom: calc(100% + var(--s2));
right: 0;
z-index: 20;
width: 20rem;
max-width: 80vw;
display: flex;
align-items: center;
flex-direction: column;
gap: var(--s3);
padding: var(--s3);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
text-align: left;
cursor: default;
}
.sharepanel section,
.sharepanel .field {
display: flex;
flex-direction: column;
gap: var(--s1);
}
.share select {
.sharepanel .phead {
font-size: var(--text-xs);
font-weight: 600;
color: var(--ink-muted);
}
.sharepanel select {
font-size: var(--text-xs);
padding: 2px var(--s1);
}
@@ -1597,15 +1758,7 @@
content: none;
}
/* Del med personer, under the note. */
.people-share {
display: flex;
flex-direction: column;
gap: var(--s1);
width: 100%;
}
.people-share .none {
.sharepanel .none {
font-size: var(--text-xs);
color: var(--ink-faint);
}
@@ -1673,6 +1826,15 @@
align-items: center;
gap: var(--s1);
flex-wrap: wrap;
margin-top: var(--s1);
}
/* The person picker takes its own row, so the role and the button below it never
get squeezed to nothing in the panel's width. */
.addshare select:first-child {
flex: 1 1 100%;
}
.addshare .button {
margin-left: auto;
}
.addshare select {
font-size: var(--text-xs);