Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b497b76be4 | ||
|
|
dba99b7ecc | ||
|
|
f34690f41a | ||
|
|
1bb2c9cda6 | ||
|
|
cac6c14a23 | ||
|
|
f632fb8643 | ||
|
|
eabb00ecc2 | ||
|
|
d2b5821e57 | ||
|
|
4874f7bbd8 | ||
|
|
8bf4f8fbe4 | ||
|
|
84af63316d | ||
|
|
930522ecf6 | ||
|
|
9ed6f49589 | ||
|
|
de673260a3 | ||
|
|
804c81f74e | ||
|
|
87054ac8ca | ||
|
|
c62dcc3d7c | ||
|
|
0110a86858 | ||
|
|
c868be8f89 | ||
|
|
145fefea02 | ||
|
|
5d911d40fe | ||
|
|
9788bbaaf8 | ||
|
|
f68d6a8c15 | ||
|
|
ff0447317b | ||
|
|
a2f6e9bda3 | ||
|
|
1bbd36c79b | ||
|
|
54359f8d68 | ||
|
|
f400705bb7 | ||
|
|
0dc4c2f9b8 | ||
|
|
7f09b35db5 | ||
|
|
0f4048d8f2 |
+18
-10
@@ -1,13 +1,21 @@
|
||||
name: Documentation
|
||||
|
||||
# The docs are built on every push so a broken build is caught immediately, but
|
||||
# only published once GitHub Pages has been enabled for the repository. Until the
|
||||
# owner decides to publish, this job builds and stops.
|
||||
# Built on every push, and published from main.
|
||||
#
|
||||
# And on pull requests, where the build is the only check there is: `mkdocs build
|
||||
# --strict` fails on a link to a page that does not exist, and building only after
|
||||
# the merge means finding that out on `main`. Nothing is published from a pull
|
||||
# request — the `deploy` job below runs on a manual dispatch and nothing else.
|
||||
# The publishing used to wait for a manual dispatch, with a note saying it would
|
||||
# run "once Pages is enabled". Pages was enabled — and the condition was never
|
||||
# changed, so the site was built forty times and published none of them. What it
|
||||
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
|
||||
# all answered 404, months after they were written. Nothing failed, because
|
||||
# building *is* what the job was asked to do.
|
||||
#
|
||||
# The lesson is not about this line. It is that "publish later" needs something
|
||||
# that fails when later arrives — a switch that is off and silent looks exactly
|
||||
# like a switch that is on.
|
||||
#
|
||||
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
|
||||
# link to a page that does not exist, and building only after the merge means
|
||||
# finding that out on `main`.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -54,9 +62,9 @@ jobs:
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Only runs once Pages is enabled. Until then the build above still guards
|
||||
# against a broken docs tree.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# From main, and by hand. Never from a pull request — a fork could otherwise
|
||||
# publish over the site by opening one.
|
||||
if: github.event_name != 'pull_request'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
|
||||
@@ -72,13 +72,44 @@ jobs:
|
||||
#
|
||||
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
|
||||
# side, der linker til sig selv, hjælper ingen.
|
||||
#
|
||||
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
|
||||
# actions/checkout henter først alle tags korrekt — og overskriver derefter
|
||||
# netop den ene, der udløste kørslen:
|
||||
#
|
||||
# git rev-parse refs/tags/v0.46.0
|
||||
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
|
||||
#
|
||||
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
|
||||
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
|
||||
# commitbeskeden, og det var flettecommittens overskrift, der stod som
|
||||
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
|
||||
#
|
||||
# Derfor hentes tagget som *ref* igen her, før det læses.
|
||||
- id: notes
|
||||
run: |
|
||||
git fetch --force origin \
|
||||
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
|
||||
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||||
|
||||
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
|
||||
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
|
||||
# frem for at blive opdaget på siden bagefter.
|
||||
case "$body" in
|
||||
"${GITHUB_REF_NAME}"*) ;;
|
||||
*)
|
||||
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
|
||||
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo 'body<<VERDANDE_EOF'
|
||||
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
|
||||
printf '%s\n' "$body"
|
||||
echo VERDANDE_EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ Which is what a to-do list is.
|
||||
All six phases are built. See [What works today](#what-works-today) for what is
|
||||
tested and what is not.
|
||||
|
||||
Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
||||
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
|
||||
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
|
||||
|
||||
## Why this exists
|
||||
|
||||
@@ -43,6 +44,10 @@ and an MCP endpoint so Claude can work your task list directly.
|
||||
the text, which shares it with her and tells her. `#project` and
|
||||
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||
- **Notes are sealed at rest.** Title and body are encrypted in the database under
|
||||
a key in a file beside it — the title too, because a title beside a sealed body
|
||||
leaves the beginning of every note in the open. A copy of the file is not a copy
|
||||
of the notes.
|
||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||
a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||
@@ -128,6 +133,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
|
||||
| `@name` in a note shares it with that person, and tells them | Done, tested |
|
||||
| Share a project with somebody who already has an account, by name | Done, tested |
|
||||
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
|
||||
| Told when a project is shared with you — and able to leave it | Done, tested |
|
||||
| Push from other programs: a webhook address per person | Done, tested |
|
||||
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
|
||||
| Search the settings, from the page or from ⌘K | Done, tested |
|
||||
| Choose which browser a link opens in | Done, tested |
|
||||
| Images in notes: open full size, copy to the clipboard | Done, tested |
|
||||
| Install count: two values, once a day, one click to stop | Done, tested |
|
||||
|
||||
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
|
||||
@@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline
|
||||
it is a document that can run script, and a note is not a place to run somebody
|
||||
else's.
|
||||
|
||||
**Click one to see it whole.** In the note it is scaled down to the width of the
|
||||
column — a photo off a phone is four thousand pixels across — so what you are
|
||||
looking at is a thumbnail that does not look like one. The full-size view has a
|
||||
button that copies the picture to the clipboard, and a link that opens it in its
|
||||
own tab. Escape closes it and puts the cursor back where it was.
|
||||
|
||||
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
|
||||
little else, and a photo off a phone is a JPEG — without the conversion the copy
|
||||
either fails or succeeds with something nothing will paste.
|
||||
|
||||
### Code blocks
|
||||
|
||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||
@@ -128,6 +138,14 @@ Search finds a word wherever it is:
|
||||
body. A word in the title is what the note is about; the same word once in a long
|
||||
body is a mention.
|
||||
|
||||
Two places to search from, and they now agree. The field above the list filters it
|
||||
in place. **⌘K** searches everything at once — tasks, projects, notes and the
|
||||
settings — and picking a note from it opens that note *with the rest of the matches
|
||||
still in the list beside it*. It used to drop you on the ordinary list, so the
|
||||
other results existed only in the popup you had just closed. The address carries
|
||||
the search, which also means a search worth keeping can be bookmarked or sent to
|
||||
somebody.
|
||||
|
||||
## Putting notes away
|
||||
|
||||
Two different things, and the difference matters:
|
||||
|
||||
@@ -11,6 +11,7 @@ the app's icon.
|
||||
| --- | --- |
|
||||
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
|
||||
| Somebody shared a note with you | Once, when it happens |
|
||||
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
|
||||
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
|
||||
| Somebody gave you a task | Only on the change, and only to the person who got it |
|
||||
| Somebody commented | |
|
||||
|
||||
+12
-1
@@ -2379,8 +2379,19 @@ paths:
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
delete:
|
||||
tags: [projects]
|
||||
summary: Remove somebody from a project. Its owner only.
|
||||
summary: Remove somebody from a project — its owner, or yourself.
|
||||
description: |
|
||||
Two things through one route: the owner taking somebody off, and anybody
|
||||
taking themselves off. The second is what makes a share something you can
|
||||
undo. You are let into a project without being asked, so a way out that
|
||||
does not go through asking its owner is the other half of that.
|
||||
|
||||
The owner cannot leave their own project. That is a transfer of ownership,
|
||||
not an exit, and there is no transfer yet.
|
||||
|
||||
Pointing at somebody else's membership without owning the project answers
|
||||
404 rather than 403: saying "you may not" confirms the membership exists.
|
||||
|
||||
Their tasks are unassigned rather than left pointing at somebody who can
|
||||
no longer see the project — which is why changing a role is a PATCH and
|
||||
not a remove-and-reinvite.
|
||||
|
||||
@@ -24,6 +24,16 @@ becomes a link that creates their account and their membership at once. With no
|
||||
mail server configured the link is shown in the panel instead of being sent, which
|
||||
is the correct behaviour on a one-person instance rather than an error.
|
||||
|
||||
**You are told, and you can leave.** Being added to a project is not something
|
||||
that happens to you quietly: the bell says who shared what, and the entry takes
|
||||
you to the project. In the share panel, anybody who is not the owner has a *Leave
|
||||
the project* button. That is deliberate rather than an approval step — an
|
||||
invitation waiting on a yes means the person who shared thinks it is done while
|
||||
the work sits still. What was wrong was the other three-quarters of it: you were
|
||||
added without being asked, without being told, and with no way out except asking
|
||||
the owner. The owner cannot leave their own project; that is a transfer of
|
||||
ownership, and there is no transfer yet.
|
||||
|
||||
An invitation that has not been taken up stays listed under the members, greyed,
|
||||
with a × to withdraw it. Without that it is invisible — the person has no account,
|
||||
so they are neither a member nor somebody you can pick — and the usual next move
|
||||
|
||||
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
|
||||
})
|
||||
}
|
||||
|
||||
// notifyProjectShared tells somebody that a project is now theirs to work in.
|
||||
//
|
||||
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
|
||||
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
|
||||
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
|
||||
// opgaver til dig i det.
|
||||
//
|
||||
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
|
||||
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
|
||||
// handling — de skal ikke have besked om det, de lige selv gjorde.
|
||||
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
|
||||
if project == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
if actor.ID == userID {
|
||||
return
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
|
||||
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||
// it — folded into one line per note while it is unread.
|
||||
//
|
||||
|
||||
@@ -624,6 +624,9 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
||||
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
|
||||
// de kiggede — uden at noget sagde hvornår eller fra hvem.
|
||||
s.notifyProjectShared(r, project, person.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"added": true,
|
||||
// Personen sendes med retur, fordi fladen kan have delt med en adresse
|
||||
@@ -707,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
|
||||
// anybody removing themselves.
|
||||
//
|
||||
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
|
||||
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
|
||||
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
|
||||
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
|
||||
//
|
||||
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
|
||||
// overdragelse, og den findes ikke endnu.
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
me := userFrom(r.Context())
|
||||
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
|
||||
// persons medlemskab, bekræfter at medlemskabet findes.
|
||||
if me.ID != owner && userID != me.ID {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"the owner cannot be removed from their own project")
|
||||
|
||||
@@ -627,3 +627,98 @@ func hasPerson(raw any, id string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
|
||||
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
|
||||
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
anden := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
andenUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": andenUser.ID, "role": "editor"})
|
||||
|
||||
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
|
||||
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
|
||||
var shared map[string]any
|
||||
for _, raw := range inbox["notifications"].([]any) {
|
||||
if n := raw.(map[string]any); n["kind"] == "project.shared" {
|
||||
shared = n
|
||||
}
|
||||
}
|
||||
if shared == nil {
|
||||
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
|
||||
}
|
||||
if shared["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
|
||||
shared["project_id"], projectID)
|
||||
}
|
||||
|
||||
// Udgangen.
|
||||
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// En udgang for en selv er ikke en dør til de andres medlemskaber.
|
||||
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
en := ts.newUser(t, "en@example.dk", "En")
|
||||
ts.newUser(t, "to@example.dk", "To")
|
||||
|
||||
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toUser, err := ts.db.UserByEmail(t.Context(), "to@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
for _, u := range []string{enUser.ID, toUser.ID} {
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": u, "role": "editor"})
|
||||
}
|
||||
|
||||
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
|
||||
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if len(members["members"].([]any)) != 3 {
|
||||
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
|
||||
}
|
||||
}
|
||||
|
||||
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
|
||||
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
|
||||
resp, _ := ts.do(t, "DELETE",
|
||||
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +493,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/", s.handleGetProject)
|
||||
r.Get("/sections", s.handleListSections)
|
||||
r.Get("/members", s.handleListMembers)
|
||||
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
r.Get("/activity", s.handleActivity)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -508,7 +511,6 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/invites", s.handleInvite)
|
||||
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+67
-11
@@ -819,6 +819,15 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
<!-- Besøgstælling for denne side — ikke for programmet, som melder det, der
|
||||
står under "It counts itself". Plausible på vores egen maskine: ingen
|
||||
cookie, ingen gemt adresse, ingen tredjepart, og derfor ingen bjælke at
|
||||
møde folk med. `defer`, så tælleren aldrig holder siden tilbage. -->
|
||||
<script
|
||||
defer
|
||||
data-domain="verdandetodo.com"
|
||||
src="https://plausible.yggdrasilpanel.com/js/script.outbound-links.js"
|
||||
></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -1326,6 +1335,64 @@
|
||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||
</p>
|
||||
<ul class="lately">
|
||||
<li>
|
||||
<span class="ver">0.47</span>
|
||||
Click a picture in a note and it opens at full size, with a button that
|
||||
copies it to the clipboard. In the note it is scaled to the width of the
|
||||
column — a photo off a phone is four thousand pixels across — so what you
|
||||
were looking at was a thumbnail that did not look like one.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.47</span>
|
||||
Search in ⌘K, pick a note, and the rest of the matches stay in the list
|
||||
beside it. They used to exist only in the popup you had just closed.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.46</span>
|
||||
Share a project with somebody who already has an account by picking their
|
||||
name, rather than by spelling an email address you would have to know.
|
||||
Invitations that have not been taken up stay visible so they do not get
|
||||
sent twice, and can be withdrawn.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.46</span>
|
||||
You are told when a project is shared with you, and there is a
|
||||
<strong>Leave the project</strong> button. Being let in without being
|
||||
asked, without being told, and with no way out was three things at once.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.45</span>
|
||||
Search for a setting instead of remembering which tab it was under — in
|
||||
the settings themselves and in ⌘K. It knows them by what they do, so
|
||||
<em>beacon</em> finds the install count even though that is not its name.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.45</span>
|
||||
Today's plan is a card on <strong>I dag</strong> rather than only a
|
||||
message in the morning that was gone if you did not read it at once. And
|
||||
a link can be told which browser to open in.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.44</span>
|
||||
An optional AI layer, on your own key: it tidies the inbox, pulls the
|
||||
tasks out of a meeting note, writes a morning plan, turns
|
||||
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
|
||||
for, and answers questions about your own notes with links to what the
|
||||
answer rests on. Every one of them is a line you accept, edit or refuse —
|
||||
it suggests, it never writes.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.44</span>
|
||||
Administrators are told in the bell when a new version is out, once per
|
||||
version, and the message leads to the button that restarts.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.43</span>
|
||||
Anything that can call a URL can put a task in your inbox — a shortcut on
|
||||
your phone, a script, a service. Notes can be handed to somebody who has
|
||||
no account yet, and writing <code>@Sofie</code> in a note shares it with
|
||||
her and tells her. Unread counts show on the app's icon in the Dock.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.42</span>
|
||||
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
||||
@@ -1362,17 +1429,6 @@
|
||||
body to move it. A single note can be shared with one person, and a
|
||||
<code>[[note]]</code> is a link you follow inline.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.34</span>
|
||||
Sections can be dragged into the order the work actually happens in. The
|
||||
sidebar keeps its head still while the projects scroll.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.33</span>
|
||||
Notes grew an archive, selection of many at once, pictures you can paste
|
||||
or drag in, a source view, and a copy button on code blocks. Search
|
||||
stopped answering by date and started answering by relevance.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
+756
-13
@@ -38,6 +38,61 @@ async function projectAction(page, name) {
|
||||
await item.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Venter på, at en gemning faktisk er nået frem til serveren.
|
||||
*
|
||||
* Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note
|
||||
* står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på
|
||||
* siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En
|
||||
* prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand
|
||||
* og fejler på noget, der ikke er i stykker.
|
||||
*
|
||||
* Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet
|
||||
* siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den
|
||||
* første overhovedet er begyndt. En ventetid på den er grøn med det samme og
|
||||
* venter på ingenting. Det gjorde fem prøver i den her fil ustabile.
|
||||
*
|
||||
* En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er
|
||||
* et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl.
|
||||
*
|
||||
* Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det.
|
||||
* Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske
|
||||
* allerede kommet og gået.
|
||||
*/
|
||||
function gemt(side, sti) {
|
||||
return side.waitForResponse(
|
||||
(r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok()
|
||||
);
|
||||
}
|
||||
|
||||
const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/);
|
||||
const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/);
|
||||
const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/);
|
||||
|
||||
/**
|
||||
* Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i.
|
||||
*
|
||||
* `create()` venter på serverens svar, før noten er oprettet, og imens står den
|
||||
* forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af
|
||||
* tastetryk med det samme skriver derfor i den forrige note — teksten havner det
|
||||
* forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning
|
||||
* findes den titel, prøven leder efter, slet ikke.
|
||||
*
|
||||
* Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses,
|
||||
* før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter.
|
||||
*
|
||||
* Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver
|
||||
* straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal
|
||||
* maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt.
|
||||
*/
|
||||
async function nyNote(side) {
|
||||
await side.getByRole('button', { name: 'Ny note' }).click();
|
||||
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(felt).toHaveText('');
|
||||
await felt.click();
|
||||
return felt;
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
@@ -1683,6 +1738,11 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
// HTML5 drag occasionally does not register the first time under CI load, and a
|
||||
// single dragTo then leaves the group empty and the page assertion failing for a
|
||||
// reason that has nothing to do with what the test is about.
|
||||
// Sidebjælken viser flytningen med det samme og sender PATCH'en bagefter, så
|
||||
// `nested` er sand, før serveren har hørt om den. Klikker prøven videre i det
|
||||
// hul, henter gruppesiden en gruppe uden projekter i — og fejler på noget, der
|
||||
// ikke er i stykker. Det er samme form som beskrivelsen længere nede.
|
||||
const flyttet = projektGemt(page);
|
||||
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
|
||||
await expect(async () => {
|
||||
await sidebar
|
||||
@@ -1690,6 +1750,7 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||
await expect(nested).toHaveCount(1, { timeout: 1000 });
|
||||
}).toPass({ timeout: 10000 });
|
||||
await flyttet;
|
||||
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
||||
@@ -1703,8 +1764,21 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
const about = page.getByLabel('Om gruppen');
|
||||
await expect(about).toBeVisible();
|
||||
await about.fill('Alt der larmer og støver.');
|
||||
|
||||
// Gemningen skal være nået frem, FØR der genindlæses. Beskrivelsen tegnes
|
||||
// optimistisk — siden sætter teksten på gruppen med det samme og sender PATCH'en
|
||||
// bagefter — så en kontrol på teksten er grøn, før serveren har hørt om den. En
|
||||
// reload i det hul henter den gamle gruppe tilbage, og prøven fejler på noget,
|
||||
// der ikke er i stykker. Svaret er det, der kan ventes på.
|
||||
const gemt = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PATCH' &&
|
||||
new URL(r.url()).pathname.includes('/project-groups/') &&
|
||||
r.ok()
|
||||
);
|
||||
await about.blur();
|
||||
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
||||
await gemt;
|
||||
|
||||
// And it survives a reload, which is the difference between the page keeping up
|
||||
// and the server taking it.
|
||||
@@ -2724,7 +2798,9 @@ test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilb
|
||||
// And the round trip, which is the thing that can quietly ruin a note: what is
|
||||
// stored is Markdown, and reopening it has to give back the same document. A
|
||||
// converter that disagrees with itself reshapes a note a little on every save.
|
||||
const rundtur = noteGemt(page);
|
||||
await page_.blur();
|
||||
await rundtur;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Møde med Anders/ }).click();
|
||||
|
||||
@@ -2911,8 +2987,9 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
|
||||
await body.click();
|
||||
await gæst.keyboard.press('Control+End');
|
||||
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
|
||||
const gemt = noteGemt(gæst);
|
||||
await body.blur();
|
||||
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
|
||||
await gemt;
|
||||
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||
@@ -2975,10 +3052,33 @@ test('en delt note tager de noter med, den peger på', async ({ browser, page })
|
||||
'Aftale om levering\nse [[Prisliste i kroner]]'
|
||||
]) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
// Tomt felt før der skrives, og rækken i listen bagefter. IKKE "Gemt" i
|
||||
// foden: den tekst er hvilestanden — der står "Gemmer" mens en gemning
|
||||
// kører og "Gemt" ellers, altså også før den første er begyndt. En ventetid
|
||||
// på den er grøn med det samme og venter på ingenting.
|
||||
await expect(felt).toHaveText('');
|
||||
await felt.click();
|
||||
await page.keyboard.type(text);
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||
await felt.blur();
|
||||
const titel = text.split('\n')[0];
|
||||
// Rækkens egen titel, og kun den. To fælder ligger på vejen hertil, og jeg
|
||||
// gik i begge:
|
||||
//
|
||||
// `filter({ hasText: 'Aftale om levering' })` med en STRENG matcher
|
||||
// delstreng og uden hensyn til store og små bogstaver — den fandt også
|
||||
// "Fælles aftale om leveringen" fra en tidligere prøve, for databasen deles
|
||||
// af hele suiten.
|
||||
//
|
||||
// `.notes` alene rækker for langt: en `[[henvisning]]` i editoren er også
|
||||
// tekst med den titel i, så et opslag på teksten fandt tre.
|
||||
//
|
||||
// `button.row strong` er rækkens titelelement, og en forankret regex er både
|
||||
// eksakt og versalfølsom. Titlerne her er almindelige ord uden
|
||||
// regex-tegn — står der en dag en parentes i en af dem, skal den undviges.
|
||||
await expect(
|
||||
page.locator('.notes button.row strong').filter({ hasText: new RegExp(`^${titel}$`) })
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||
@@ -3064,8 +3164,9 @@ test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ p
|
||||
|
||||
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
|
||||
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
|
||||
const kildeGemt = noteGemt(page);
|
||||
await body.blur();
|
||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||
await kildeGemt;
|
||||
await page.getByRole('button', { name: /Vis kilden/ }).click();
|
||||
await expect(page.locator('textarea.source')).toHaveValue(/`/);
|
||||
|
||||
@@ -3137,8 +3238,11 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
||||
await expect(inline).toBeVisible();
|
||||
|
||||
// It survives the round-trip through Markdown and a reload.
|
||||
// Gemningen, ikke en ventetid på 900 ms. Tallet var gættet til at overleve
|
||||
// tastepausen på 700 ms, og et gæt om tid måler maskinen frem for programmet.
|
||||
const linketGemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(900);
|
||||
await linketGemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Mine planer/ }).click();
|
||||
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
|
||||
@@ -3675,9 +3779,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
||||
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();
|
||||
const ed = await nyNote(page);
|
||||
await page.keyboard.type('Titel');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
@@ -3713,8 +3815,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
||||
|
||||
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
|
||||
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await ed.click();
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Rundtur');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
@@ -3722,8 +3823,9 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
||||
await page.keyboard.type('docker pull');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('og så videre i almindelig skrift');
|
||||
const rundturGemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(1200);
|
||||
await rundturGemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Rundtur/ }).click();
|
||||
await expect(ed.locator('pre')).toContainText('docker pull');
|
||||
@@ -4025,9 +4127,10 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
|
||||
// Og datoen kan rettes bagefter uden at tage timen med sig.
|
||||
const date = page.locator('#due');
|
||||
const day = await date.inputValue();
|
||||
const datoGemt = opgaveGemt(page);
|
||||
await date.fill(day);
|
||||
await date.blur();
|
||||
await page.waitForTimeout(400);
|
||||
await datoGemt;
|
||||
await page.reload();
|
||||
await page.getByText('ringe til tandlægen').click();
|
||||
await expect(
|
||||
@@ -4597,5 +4700,645 @@ test('et projekt kan deles med en, der allerede har en konto', async ({ browser,
|
||||
// spring tilbage til det første projekt, og delingspanelet står åbent på tværs
|
||||
// af det spring — så klikket på "Del" lukkede det, jeg lige havde åbnet.
|
||||
|
||||
// Og så den anden vej: hvad Gartner selv oplever. Man bliver lukket ind i et
|
||||
// projekt uden at blive spurgt, så man skal i det mindste få det at vide og
|
||||
// kunne gå ud igen uden at bede ejeren om lov.
|
||||
const theirs = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const gartner = await theirs.newPage();
|
||||
const gartnerTrouble = watchForTrouble(gartner);
|
||||
await gartner.goto('/');
|
||||
await gartner.getByLabel('E-mail').fill('gartner@example.dk');
|
||||
await gartner.getByLabel('Adgangskode').fill('et langt kodeord til test');
|
||||
await gartner.getByRole('button', { name: 'Log ind' }).click();
|
||||
await expect(gartner.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
await gartner.getByRole('button', { name: /Beskeder/ }).click();
|
||||
const bell = gartner.getByRole('dialog', { name: 'Beskeder' });
|
||||
await expect(bell.getByText('delte et projekt med dig')).toBeVisible();
|
||||
|
||||
// Beskeden fører hen til projektet. En besked, der ikke kan følges, er en
|
||||
// oplysning om noget, man selv skal gå ud og finde.
|
||||
await bell.locator('.row').filter({ hasText: 'delte et projekt med dig' }).click();
|
||||
await expect(gartner.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
|
||||
|
||||
// Udgangen. Bekræftelsen siges ja til, ellers sker der intet.
|
||||
gartner.on('dialog', (d) => d.accept());
|
||||
await projectAction(gartner, /^Del/);
|
||||
await gartner.getByRole('button', { name: 'Forlad projektet' }).click();
|
||||
|
||||
// Væk fra sidebjælken, og ikke længere til at åbne.
|
||||
await expect(
|
||||
gartner.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Drivhuset/ })
|
||||
).toHaveCount(0);
|
||||
|
||||
// En 404 her er ikke uro: den er svaret på at bede om et projekt, man lige har
|
||||
// forladt, og den er hele pointen med at have forladt det.
|
||||
expect(gartnerTrouble.filter((t) => !t.includes('404'))).toEqual([]);
|
||||
await theirs.close();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En søgning i ⌘K efterlader resultaterne, den fandt.
|
||||
*
|
||||
* Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
|
||||
* åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
|
||||
* væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
|
||||
* lukket. Rapporteret sådan her: "jeg får kun præsenteret en popup liste med
|
||||
* resultater, men hvis jeg trykker retur, kommer jeg tilbage til den normale liste
|
||||
* — ikke resultater."
|
||||
*
|
||||
* Prøven laver sine egne noter med et ord, ingen anden note i basen har. Et ord
|
||||
* lånt fra en tidligere prøve ville gøre den her afhængig af rækkefølgen, og den
|
||||
* kobling har allerede narret mig én gang i den her fil.
|
||||
*/
|
||||
test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
|
||||
// Gem-knap at huske.
|
||||
//
|
||||
// Der ventes på et TOMT tekstfelt, før der skrives. Uden det kan udfyldningen
|
||||
// ramme den forrige notes felt, mens den nye er undervejs, og så bliver teksten
|
||||
// enten skrevet på den forkerte note eller tabt undervejs.
|
||||
//
|
||||
// Og der ventes IKKE på "Gemt": den tekst er hvilestanden, ikke et signal.
|
||||
// Feltet siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før
|
||||
// den første gemning overhovedet er begyndt. En prøve, der venter på den, får
|
||||
// grønt med det samme og måler ingenting. Det var den fejl, der gjorde den her
|
||||
// prøve ustabil: rettelsen var en ventetid, der ikke kunne vente på noget.
|
||||
// Rækken i listen er det rigtige tegn — den kommer først, når serveren har
|
||||
// svaret.
|
||||
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await expect(body).toHaveText('');
|
||||
await body.fill(`${title}\n\nnoget om den`);
|
||||
await body.blur();
|
||||
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /Søg/ }).click();
|
||||
const palette = page.getByRole('dialog');
|
||||
await palette.getByRole('textbox').fill('Fyrreskov');
|
||||
await expect(palette.locator('li button', { hasText: 'Fyrreskov nord' })).toBeVisible();
|
||||
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
|
||||
|
||||
// Noten er åben …
|
||||
await expect(page).toHaveURL(/\/noter\?note=[^&]+&q=Fyrreskov/);
|
||||
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
|
||||
|
||||
// … og listen ved siden af den er de andre træffere, ikke den normale liste.
|
||||
await expect(page.getByPlaceholder('Søg i noter')).toHaveValue('Fyrreskov');
|
||||
const list = page.locator(".list");
|
||||
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
|
||||
await expect(list.getByText('Egeskov')).toHaveCount(0);
|
||||
|
||||
// Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede,
|
||||
// og den blev læst igen hver gang noget ændrede sig — så et klik på en anden
|
||||
// række satte `selectedId`, effekten så den uenig med adressen, og trak én
|
||||
// tilbage til den første. Man kunne søge og ikke vælge.
|
||||
await list.getByText('Fyrreskov syd').click();
|
||||
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd');
|
||||
await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord');
|
||||
|
||||
// Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at
|
||||
// åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger
|
||||
// stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og
|
||||
// huskede værdien, ville se den uændret og lade være med at åbne noget. Man
|
||||
// kunne så ikke komme tilbage til den note, man kom fra.
|
||||
await page.getByRole('button', { name: /Søg/ }).click();
|
||||
await palette.getByRole('textbox').fill('Fyrreskov nord');
|
||||
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
|
||||
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
|
||||
|
||||
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
|
||||
// den skrives kun af navigation. Den første udgave skrev den ved hvert
|
||||
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
|
||||
// prøven nedenfor.
|
||||
await page.getByPlaceholder('Søg i noter').fill('');
|
||||
await expect(list.getByText('Egeskov')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En ny note åbnes med det samme, uden at editoren forsvinder undervejs.
|
||||
*
|
||||
* Serveren tømmer `body` på listerækker med vilje, og siden brugte den tomme krop
|
||||
* som tegn på "det her er en række, hent noten hel". En nyoprettet note er også
|
||||
* tom — og hel — så den blev sendt samme vej: editoren blev pillet af skærmen,
|
||||
* noten hentet igen, og editoren sat op på ny.
|
||||
*
|
||||
* Det hul var ikke kun et blink. Mens det stod åbent, var der ingen valgt note, og
|
||||
* gemningen går tavst tilbage uden en. Skrev man i det sekund, var teksten væk —
|
||||
* ingen fejl, ingen toast, bare en tom note. Det er også det, der gjorde
|
||||
* søgeprøven ovenfor ustabil: i CI var maskinen langsom nok til at ramme hullet.
|
||||
*
|
||||
* Prøven tæller opslagene frem for at måle et blink. Et blink er en tidsting og
|
||||
* ville kræve, at prøven var hurtigere end programmet — præcis den fejl, den er
|
||||
* skrevet for at lukke. Et opslag, der ikke sker, er et tal.
|
||||
*/
|
||||
test('en ny note hentes ikke igen, når den lige er lavet', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
const fetched = [];
|
||||
page.on('request', (r) => {
|
||||
// Selve noten, ikke dens delinger: /notes/<id> og ikke /notes/<id>/shares.
|
||||
const m = new URL(r.url()).pathname.match(/^\/api\/v1\/notes\/([0-9a-f-]+)$/);
|
||||
if (r.method() === 'GET' && m) fetched.push(m[1]);
|
||||
});
|
||||
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
|
||||
// Editoren er der, og den er tom — ikke væk og på vej tilbage.
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await expect(body).toHaveText('');
|
||||
|
||||
await body.fill('Grantræ\n\nnoget om det');
|
||||
await body.blur();
|
||||
await expect(page.locator('.list').getByText('Grantræ', { exact: true })).toBeVisible();
|
||||
|
||||
// Noten blev aldrig hentet igen: svaret på oprettelsen var den hele.
|
||||
expect(fetched).toEqual([]);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Et totalt udfald siger "ingen forbindelse" — ikke "log ind".
|
||||
*
|
||||
* Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står `user`
|
||||
* som null, og null betyder "logget ud" alle andre steder i programmet — så et
|
||||
* udfald blev vist som login-skærmen. Appen gav altså sessionen skylden for, at
|
||||
* nettet var nede, og tilbød et kodeordsfelt, der umuligt kunne virke.
|
||||
*
|
||||
* Det er værre end en fejlbesked, fordi det ligner noget, der kunne virke: man
|
||||
* taster kodeordet, der sker ingenting, og man konkluderer, at kontoen er i
|
||||
* stykker. Det skete i virkeligheden 17. september 2026, da instansens DNS-post
|
||||
* blev slettet.
|
||||
*
|
||||
* Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt forespørgsel
|
||||
* er præcis det, en manglende DNS-post giver browseren — fetch afviser, uden at der
|
||||
* nogensinde kom en status. En 500 ville være serveren, der svarer, og det er den
|
||||
* anden sag.
|
||||
*/
|
||||
test('et udfald siger ingen forbindelse frem for at bede om kodeordet', async ({ page }) => {
|
||||
await page.route('**/api/v1/**', (route) => route.abort());
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Prøvetiden skal kunne rumme hele forsøgsvinduet: forespørgselslaget prøver
|
||||
// igen i femten sekunder, før det kalder det et udfald. Det er med vilje —
|
||||
// et blink på nettet må ikke smide folk ud — så prøven må vente det ud.
|
||||
await expect(page.getByText('Ingen forbindelse', { exact: true })).toBeVisible({
|
||||
timeout: 25000
|
||||
});
|
||||
|
||||
// Og ikke kodeordsfeltet. Det er hele pointen.
|
||||
await expect(page.getByLabel('Adgangskode')).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Klokkens panel bliver inden for skærmen på en telefon.
|
||||
*
|
||||
* Panelet hang under klokken og var 340 px bredt. Klokken sidder tættere på højre
|
||||
* kant end det — der er en temaknap og en kantafstand til højre for den — så
|
||||
* panelet blev skubbet ud over venstre side og klippet af. På en telefon stod der
|
||||
* flere linjer tekst uden for skærmen, og overskriften "Beskeder" lå klods op ad
|
||||
* kanten uden luft, hvilket er tegnet på, at der mangler noget til venstre for
|
||||
* den.
|
||||
*
|
||||
* Målt på kasserne frem for på et skærmbillede: "ser rigtigt ud" er ikke en
|
||||
* påstand, en prøve kan efterprøve, men "venstre kant er ikke negativ" er. Begge
|
||||
* kanter tjekkes — en rettelse, der skubber panelet ind fra venstre ved at lade
|
||||
* det stikke ud til højre, er ikke en rettelse.
|
||||
*/
|
||||
test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('button', { name: /^Beskeder/ }).click();
|
||||
const panel = page.getByRole('dialog', { name: 'Beskeder' });
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const kasse = await panel.boundingBox();
|
||||
expect(kasse.x, 'panelet hænger ud over venstre kant').toBeGreaterThanOrEqual(0);
|
||||
expect(kasse.x + kasse.width, 'panelet hænger ud over højre kant').toBeLessThanOrEqual(390);
|
||||
|
||||
// Og der er luft til overskriften. Et panel, der lige akkurat begynder ved nul,
|
||||
// men hvis tekst gør det samme, er stadig klippet — det var netop sådan, det så
|
||||
// ud på telefonen.
|
||||
const overskrift = panel.getByText('Beskeder', { exact: true });
|
||||
const tekst = await overskrift.boundingBox();
|
||||
expect(tekst.x, 'overskriften klæber til kanten').toBeGreaterThan(kasse.x);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En punktliste kan have niveauer, og de overlever gemningen.
|
||||
*
|
||||
* Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var
|
||||
* ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med
|
||||
* Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to.
|
||||
*
|
||||
* Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown —
|
||||
* men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger
|
||||
* browseren den som søskende, og det gør de uens, blev grenen sprunget over uden
|
||||
* lyd: punktet forsvandt ved gemningen. Begge former læses nu.
|
||||
*
|
||||
* Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad —
|
||||
* det er turen gennem Markdown, der afgør, om den blev skrevet ned.
|
||||
*/
|
||||
test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Indkøb');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Punktliste' }).click();
|
||||
await page.keyboard.type('Frugt');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Aebler');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
// Niveauet er der med det samme: en liste inde i en liste.
|
||||
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
|
||||
|
||||
const gemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await gemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Indkøb/ }).click();
|
||||
|
||||
// Og efter turen gennem Markdown står det der stadig.
|
||||
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
|
||||
await expect(ed.locator('ul > li').first()).toContainText('Frugt');
|
||||
|
||||
// Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør.
|
||||
await ed.locator('ul ul li').click();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(ed.locator('ul ul li')).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Farvelægningen skriver ikke linjerne sammen i en blok, man lige har forladt.
|
||||
*
|
||||
* Blokken farves, når markøren forlader den, og resultatet males TILBAGE ind i
|
||||
* den. Den læsning havde sin egen kopi af "hvad er teksten i en blok", og kopien
|
||||
* brugte `textContent` — hvor et <br> er den tomme streng. Så en blok skrevet med
|
||||
* shift+retur fik sine linjer skrevet sammen i det øjeblik, man klikkede væk. På
|
||||
* skærmen, ikke kun i filen, og uden at man havde rørt teksten.
|
||||
*
|
||||
* Kommentaren over kopien sagde "ét sted, fordi to steder skal bruge det samme
|
||||
* svar". Der var to, og de var uenige om netop det tegn.
|
||||
*/
|
||||
test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Farvelagt');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('echo en');
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
await page.keyboard.type('echo to');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
// Ud af blokken, så farvelægningen tager fat i den.
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('End');
|
||||
await ed.getByText('Farvelagt', { exact: true }).click();
|
||||
|
||||
// Kopiér-knappen ligger inde i blokken for at kunne sidde i hjørnet af den, så
|
||||
// den skal ud, før teksten læses — præcis det, koden selv gør.
|
||||
const tekst = await ed.locator('pre').evaluate((el) => {
|
||||
const kopi = el.cloneNode(true);
|
||||
kopi.querySelectorAll('.copy').forEach((b) => b.remove());
|
||||
return kopi.innerText.trim();
|
||||
});
|
||||
expect(tekst).toBe('echo en\necho to');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste.
|
||||
*
|
||||
* Monotype går uden om `execCommand`, fordi formatBlock pakker et <p> ind i
|
||||
* <pre>'en i stedet for at lave den om. Den vej spurgte efter `currentBlock()`,
|
||||
* altså det ene blok markøren står i — så af en markering over ti linjers
|
||||
* terminaludskrift blev den første linje til kode og resten stod tilbage som
|
||||
* brødtekst.
|
||||
*
|
||||
* Og det skal være én blok, ikke tre. En udskrift, man indsætter, er ét stykke
|
||||
* kode; tre blokke ved siden af hinanden ser næsten ens ud på skærmen og er tre
|
||||
* hegn i filen.
|
||||
*/
|
||||
test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Udskrift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('linje et');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('linje to');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('linje tre');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed.locator('p')).toHaveCount(3);
|
||||
|
||||
// Markér alle tre, som man gør det med musen eller tastaturet: fra begyndelsen
|
||||
// af den første til enden af den sidste.
|
||||
await ed.getByText('linje et', { exact: true }).click();
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.keyboard.press('Shift+End');
|
||||
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
|
||||
// Én blok med alle tre linjer, og ingen brødtekst tilbage.
|
||||
const blok = ed.locator('pre');
|
||||
await expect(blok).toHaveCount(1);
|
||||
await expect(ed.locator('p')).toHaveCount(0);
|
||||
|
||||
const gemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await gemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Udskrift/ }).click();
|
||||
|
||||
await expect(ed.locator('pre')).toHaveCount(1);
|
||||
expect(await ed.locator('pre code').evaluate((el) => el.textContent.trim())).toBe(
|
||||
'linje et\nlinje to\nlinje tre'
|
||||
);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En kodeblok med flere linjer kommer tilbage med dem alle.
|
||||
*
|
||||
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
|
||||
* lægger ind, er et <br>, og `textContent` på et <br> er den tomme streng. Blokken
|
||||
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
|
||||
* skrive flere linjer og ikke gemme dem.
|
||||
*
|
||||
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
|
||||
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
|
||||
* ovenfor stopper lige før.
|
||||
*/
|
||||
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Opskrift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('first line');
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
await page.keyboard.type('second line');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const gemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await gemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Opskrift/ }).click();
|
||||
|
||||
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
|
||||
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
|
||||
//
|
||||
// Og på <code>, ikke på <pre>: kopiér-knappen ligger inde i blokken for at
|
||||
// kunne sidde i hjørnet af den, så `textContent` på <pre> har ordet "Kopiér"
|
||||
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
|
||||
// en blocks tekst — bare fra den anden side.
|
||||
const blok = ed.locator('pre');
|
||||
await expect(blok).toHaveCount(1);
|
||||
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
|
||||
'first line\nsecond line'
|
||||
);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
|
||||
*
|
||||
* I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
|
||||
* er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
|
||||
* ikke ser ud som et. Der var ingen vej fra det til billedet selv.
|
||||
*
|
||||
* Prøven indsætter billedet ad den vej, folk gør det: en rigtig paste-hændelse med
|
||||
* en fil i udklipsholderen. Et billede lagt ind ved at skrive markdown ville springe
|
||||
* hele vedhæftningen over og dermed netop den kode, det her handler om.
|
||||
*/
|
||||
test('et indsat billede kan åbnes stort og kopieres', async ({ page, context }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
// Kopiering til udklipsholderen kræver lov. Uden den fejler knappen, og prøven
|
||||
// ville måle en browserindstilling frem for programmet.
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill('Billednote');
|
||||
|
||||
// En rigtig 1×1 JPEG, og med vilje ikke en PNG: udklipsholderen tager kun PNG,
|
||||
// så et foto skal tegnes om undervejs — og et foto fra en telefon ER en JPEG.
|
||||
// Med en PNG her ville prøven køre forbi netop den kode, den skal vogte.
|
||||
await body.evaluate((el) => {
|
||||
const jpeg =
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
|
||||
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
|
||||
'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
|
||||
const bytes = Uint8Array.from(atob(jpeg), (c) => c.charCodeAt(0));
|
||||
const file = new File([bytes], 'skud.jpg', { type: 'image/jpeg' });
|
||||
const data = new DataTransfer();
|
||||
data.items.add(file);
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
const image = page.locator('[aria-label="Notens tekst"] img');
|
||||
await expect(image).toBeVisible();
|
||||
|
||||
// Klikket åbner det, og ruden viser det samme billede.
|
||||
await image.click();
|
||||
const shot = page.getByRole('dialog', { name: 'Billede' });
|
||||
await expect(shot).toBeVisible();
|
||||
await expect(shot.locator('img')).toBeVisible();
|
||||
|
||||
// Kopiér-knappen skal gøre noget. En knap, der tier, er værre end ingen knap:
|
||||
// man indsætter noget andet og opdager det et helt andet sted.
|
||||
await shot.getByRole('button', { name: 'Kopiér billedet' }).click();
|
||||
await expect(shot.getByRole('button', { name: 'Kopieret' })).toBeVisible();
|
||||
await expect(shot.getByText('Kunne ikke kopiere')).toHaveCount(0);
|
||||
|
||||
// Escape lukker, og teksten har fokus igen — ellers står fokus på en knap, der
|
||||
// ikke findes mere, og den næste tast går ingen steder.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(shot).toHaveCount(0);
|
||||
await expect(body).toBeFocused();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* En opgaves tekst kan læses færdig, også på en telefon.
|
||||
*
|
||||
* Rapporteret fra en telefon, med et billede af en beskrivelse, der manglede sit
|
||||
* første bogstav på hver linje: "hermostat RU0910301440" og "atteries". Det er
|
||||
* ikke manglende tegn — det er et felt, der er rullet til højre. En beskrivelse er
|
||||
* tit et klippet stykke mail med en URL i, et <textarea> ombryder ved mellemrum,
|
||||
* og en URL har ingen.
|
||||
*
|
||||
* Og titlen var et <input>, som ikke kan ombryde overhovedet. "tado°: Your Smart
|
||||
* Thermostat needs new batteries" er en almindelig opgavetitel, og halvdelen af
|
||||
* den stod uden for kanten.
|
||||
*/
|
||||
test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
const langTitel = 'tado°: Your Smart Thermostat needs new batteries';
|
||||
// "i dag" for at den lander på siden, prøven står på — parseren klipper det ud
|
||||
// af titlen igen, så den hedder præcis det, den skal.
|
||||
await page.getByLabel('Ny opgave').fill(`${langTitel} i dag`);
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('tado°', { exact: false }).first().click();
|
||||
|
||||
const titel = page.getByLabel('Opgavens tekst');
|
||||
await expect(titel).toBeVisible();
|
||||
await expect(titel).toHaveValue(langTitel);
|
||||
|
||||
// Beskrivelsen: en URL uden et eneste mellemrum at bryde ved.
|
||||
const url = 'https://link.tado.com/emails/8/ttid/1-a37f2c9b4e6d8a1f0c3b5e7d9a2f4c6b8e0d1a3f5c7b9e2d4a6f8c0b3e5d7a9f';
|
||||
const beskrivelse = page.getByLabel('Beskrivelse');
|
||||
await beskrivelse.fill(`Thermostat RU0910301440\nBatteries\n${url}`);
|
||||
await beskrivelse.blur();
|
||||
|
||||
// Målt frem for beskrevet: et felt, der ruller vandret, har en scrollWidth
|
||||
// større end sin clientWidth. Det var dét, der klippede venstre kant.
|
||||
//
|
||||
// Felterne måles gennem deres egne lokatorer og ikke med en forespørgsel på
|
||||
// aria-label: beskrivelsen har et <label for>, ikke et aria-label, så en
|
||||
// forespørgsel fandt ingenting og ville have bestået tomt.
|
||||
const spild = [];
|
||||
for (const [navn, felt] of [
|
||||
['Opgavens tekst', titel],
|
||||
['Beskrivelse', beskrivelse]
|
||||
]) {
|
||||
const m = await felt.evaluate((el) => ({ ind: el.scrollWidth, ude: el.clientWidth }));
|
||||
if (m.ind - m.ude > 1) spild.push(`${navn}: ${m.ind} > ${m.ude}`);
|
||||
}
|
||||
expect(spild).toEqual([]);
|
||||
|
||||
// Beskrivelsen løber ikke over i Chromium, heller ikke uden reglen — det var
|
||||
// Safari, den blev set i, og det er en anden motor. Prøven måler derfor reglen
|
||||
// selv frem for dens virkning. Det er svagere, og det står her, så den næste
|
||||
// ved det: den vogter mod at reglen bliver slettet, ikke mod at den er forkert.
|
||||
await expect(beskrivelse).toHaveCSS('overflow-wrap', 'break-word');
|
||||
|
||||
// Og titlen er blevet høj nok til at rumme sig selv frem for at klippe.
|
||||
const linjer = await titel.evaluate((el) => ({
|
||||
hoejde: el.clientHeight,
|
||||
linje: parseFloat(getComputedStyle(el).lineHeight)
|
||||
}));
|
||||
expect(linjer.hoejde).toBeGreaterThan(linjer.linje * 1.5);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* Søgefeltet taber ikke bogstaver, og listen viser det, der blev spurgt om.
|
||||
*
|
||||
* Fra en skærmoptagelse, to billeder i sekundet:
|
||||
*
|
||||
* 2,5 s feltet "verd" listen ufiltreret
|
||||
* 3,0 s feltet "nde" listen filtreret
|
||||
* 6,0 s feltet "verdande" listen ufiltreret
|
||||
* 6,5 s feltet tomt listen filtreret
|
||||
*
|
||||
* To fejl oven i hinanden.
|
||||
*
|
||||
* Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk:
|
||||
* `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes, så en effekt
|
||||
* læste den forrige adresse og skrev den gamle værdi tilbage i feltet.
|
||||
*
|
||||
* Og resultaterne landede for sent, fordi to hentninger kunne være undervejs på
|
||||
* én gang uden at nogen holdt styr på, hvilken der gjaldt. Den langsomste vandt.
|
||||
*/
|
||||
test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Sin egen note at finde. Et ord fra en anden prøve ville gøre den her
|
||||
// afhængig af rækkefølgen, og den kobling har allerede narret mig i denne fil.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const krop = page.getByLabel('Notens tekst');
|
||||
await krop.fill('Kaffemølle\n\nnoget om den');
|
||||
await krop.blur();
|
||||
// Rækken i listen er værnet. "Gemt" stod her før og er hvilestanden — se noteGemt().
|
||||
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
|
||||
|
||||
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
|
||||
// afslørede det: en pause mellem hvert bogstav skjuler kapløbet helt.
|
||||
const felt = page.getByPlaceholder('Søg i noter');
|
||||
await felt.click();
|
||||
await page.keyboard.type('kaffemølle', { delay: 40 });
|
||||
await expect(felt).toHaveValue('kaffemølle');
|
||||
|
||||
// Og det bliver stående. Fejlen viste sig et halvt sekund efter, ikke med det
|
||||
// samme — adressen skal nå at komme tilbage, før den kan skrive over noget.
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(felt).toHaveValue('kaffemølle');
|
||||
|
||||
// Det nyeste svar vinder, også når det kommer først.
|
||||
//
|
||||
// Den første søgning holdes tilbage, den anden slippes igennem. Uden vagten
|
||||
// lander den gamle sidst og overskriver listen med svaret på et spørgsmål, der
|
||||
// ikke længere bliver stillet.
|
||||
let held;
|
||||
await page.route('**/api/v1/notes?*', async (route) => {
|
||||
const q = new URL(route.request().url()).searchParams.get('q');
|
||||
if (q === 'aaaaa' && !held) {
|
||||
held = route;
|
||||
return; // holdes, indtil den næste er kommet igennem
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await felt.fill('');
|
||||
await felt.fill('aaaaa');
|
||||
await page.waitForTimeout(500);
|
||||
await felt.fill('kaffemølle');
|
||||
await page.waitForTimeout(900);
|
||||
if (held) await held.continue();
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
// Feltet og listen er enige: det, der står, er det, der blev spurgt om.
|
||||
await expect(felt).toHaveValue('kaffemølle');
|
||||
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
|
||||
|
||||
await page.unroute('**/api/v1/notes?*');
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -125,7 +125,13 @@
|
||||
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
|
||||
// op, den ER stedet.
|
||||
if (item.kind === 'setting') return goto(item.id);
|
||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
|
||||
// liste står uændret — og de andre træffere var kun at se i den popup, man
|
||||
// lige lukkede.
|
||||
if (item.kind === 'note') {
|
||||
const q = query.trim();
|
||||
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
|
||||
}
|
||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script>
|
||||
/**
|
||||
* What the app shows when it could not reach the server at all.
|
||||
*
|
||||
* Before this, an outage rendered the sign-in screen: the boot asks who you
|
||||
* are, the request never gets an answer, `user` stays null, and null means
|
||||
* "logged out" everywhere else. So the app blamed the session for the network
|
||||
* being down, and offered a password box that could not possibly work. That is
|
||||
* worse than an error message, because it looks like it might work — people
|
||||
* type the password, get nothing, and conclude their account is broken.
|
||||
*
|
||||
* It happened for real on 17 September 2026: the instance's DNS record was
|
||||
* deleted, and what the app showed was a slow-loading login page.
|
||||
*
|
||||
* The text says the data is safe, because that is the question somebody
|
||||
* actually has when their notes vanish behind a login box.
|
||||
*/
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let trying = $state(false);
|
||||
|
||||
async function again() {
|
||||
trying = true;
|
||||
try {
|
||||
await app.load();
|
||||
} finally {
|
||||
trying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
<h1>verdande</h1>
|
||||
</div>
|
||||
|
||||
<h2>{t('net.downTitle')}</h2>
|
||||
<p class="lede">{t('net.downBody')}</p>
|
||||
|
||||
<button onclick={again} disabled={trying}>
|
||||
{trying ? t('net.reconnecting') : t('net.downRetry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--s4);
|
||||
background: var(--ground);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rune {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@
|
||||
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||
* is the file to change.
|
||||
*/
|
||||
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
||||
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
|
||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
@@ -133,20 +133,78 @@
|
||||
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
|
||||
// out of the block from the menu. Do any conversion that touches a <pre> by
|
||||
// hand, replacing the element rather than reformatting inside it.
|
||||
const block = currentBlock();
|
||||
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
|
||||
replaceBlock(block, style.tag);
|
||||
//
|
||||
// Hele markeringen, ikke blokken markøren står i. Den her vej spurgte efter
|
||||
// `currentBlock()`, og det er ét blok — så en markering over ti linjers
|
||||
// terminaludskrift gjorde den første linje til kode og lod resten stå. Det
|
||||
// er den slags, man ikke opdager med to linjer og ikke kan overse med ti.
|
||||
const blocks = selectedBlocks();
|
||||
if (!blocks.length) return apply('formatBlock', style.tag);
|
||||
|
||||
if (style.tag === 'pre') {
|
||||
mergeToMono(blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ud af monotype igen: hver blok for sig, uden at røre markeringen —
|
||||
// tekstknuderne flytter med over i de nye elementer, så den range, browseren
|
||||
// holder på, stadig peger på noget, der er i dokumentet. Resten klarer
|
||||
// formatBlock bagefter, nu hvor der ikke er en <pre> i vejen.
|
||||
const pres = blocks.filter((b) => b.tagName === 'PRE');
|
||||
for (const b of pres) swapTag(b, style.tag);
|
||||
if (pres.length === blocks.length) {
|
||||
readState();
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
apply('formatBlock', style.tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a block element with one of another tag, keeping its contents and the
|
||||
* caret. Used where execCommand cannot be trusted — converting to or from a
|
||||
* <pre>, which it either nests or ignores.
|
||||
* Alle de blokke, markeringen rører — ikke kun den, markøren står i.
|
||||
*
|
||||
* En sammenfaldet markering rører præcis ét blok, så den enkle sag opfører sig
|
||||
* som før.
|
||||
*/
|
||||
function replaceBlock(block, tag) {
|
||||
function selectedBlocks() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.rangeCount || !editor) return [];
|
||||
const range = selection.getRangeAt(0);
|
||||
return [...editor.children].filter((b) => range.intersectsNode(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lægger flere blokke sammen til én monotypeblok.
|
||||
*
|
||||
* Én blok og ikke én pr. linje: en udskrift, man indsætter, er ét stykke kode.
|
||||
* Tre blokke ved siden af hinanden ser næsten ens ud på skærmen og bliver til
|
||||
* tre hegn i filen — og så kan de ikke rulles eller kopieres som det ene,
|
||||
* de er.
|
||||
*/
|
||||
function mergeToMono(blocks) {
|
||||
const pre = document.createElement('pre');
|
||||
// blockText og ikke textContent: et <br> har den tomme streng som
|
||||
// textContent, og det er netop <br>, der står for et linjeskift inde i en
|
||||
// blok. Samme grund som da en kodeblok ikke kunne gemme mere end én linje.
|
||||
pre.textContent = blocks.map((b) => blockText(b)).join('\n');
|
||||
if (!pre.textContent) pre.appendChild(document.createElement('br'));
|
||||
|
||||
blocks[0].replaceWith(pre);
|
||||
for (const b of blocks.slice(1)) b.remove();
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(pre);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
readState();
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Bytter et bloks tag ud og flytter indholdet med, uden at røre markøren. */
|
||||
function swapTag(block, tag) {
|
||||
// The copy button lives inside the <pre> so it can sit in its corner; it is
|
||||
// not text and must not travel into a paragraph. addCopyButtons puts it back
|
||||
// on the next render for any <pre> that still needs one.
|
||||
@@ -156,6 +214,16 @@
|
||||
while (block.firstChild) el.appendChild(block.firstChild);
|
||||
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
||||
block.replaceWith(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a block element with one of another tag, keeping its contents and the
|
||||
* caret. Used where execCommand cannot be trusted — converting to or from a
|
||||
* <pre>, which it either nests or ignores.
|
||||
*/
|
||||
function replaceBlock(block, tag) {
|
||||
const el = swapTag(block, tag);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
@@ -380,7 +448,7 @@
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = codeTextOf(pre);
|
||||
const text = blockText(pre);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
|
||||
btn.textContent = t('notes.copied');
|
||||
@@ -394,19 +462,16 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
|
||||
* Teksten i en kodeblok kommer fra `blockText` i richtext.js — ÉT sted, hvilket
|
||||
* den her kommentar påstod i forvejen, mens der stod en kopi lige her.
|
||||
*
|
||||
* É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.
|
||||
* De to skal bruge det samme svar: farvelægningen, som skriver resultatet
|
||||
* tilbage i blokken, og oversættelsen til Markdown, som skriver det i filen. De
|
||||
* var uenige om ét tegn — kopien her læste `textContent`, og et <br> har den
|
||||
* tomme streng som textContent. Så en blok, man havde skrevet med shift+retur,
|
||||
* fik sine linjer skrevet sammen af farvelægningen, i det øjeblik man klikkede
|
||||
* væk fra den. På skærmen, ikke kun i filen.
|
||||
*/
|
||||
function codeTextOf(pre) {
|
||||
const from = pre.querySelector('code') ?? pre;
|
||||
return [...from.childNodes]
|
||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function colourCode() {
|
||||
if (!editor) return;
|
||||
@@ -419,7 +484,7 @@
|
||||
// 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 code = blockText(pre);
|
||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||
if (lang) pre.setAttribute('data-lang', lang);
|
||||
const painted = highlight(code, lang);
|
||||
@@ -640,9 +705,69 @@
|
||||
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||
return;
|
||||
}
|
||||
// Et billede i en note er et billede, man har indsat for at kunne se det. I
|
||||
// arket er det skaleret ned til spaltens bredde — et foto fra en telefon er
|
||||
// fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede,
|
||||
// der ikke ser ud som et.
|
||||
const image = event.target.closest?.('img');
|
||||
if (image) {
|
||||
event.preventDefault();
|
||||
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||
copied = false;
|
||||
copyError = '';
|
||||
return;
|
||||
}
|
||||
colourCode();
|
||||
}
|
||||
|
||||
// --- billedet i fuld størrelse ------------------------------------------------
|
||||
|
||||
let showing = $state(null);
|
||||
let copied = $state(false);
|
||||
let copyError = $state('');
|
||||
|
||||
function closeImage() {
|
||||
showing = null;
|
||||
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
||||
// findes længere, og den næste tast går ingen steder.
|
||||
editor?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Billedet som PNG, uanset hvad det kom som.
|
||||
*
|
||||
* Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon
|
||||
* er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers
|
||||
* lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte.
|
||||
*/
|
||||
async function asPNG(src) {
|
||||
const blob = await (await fetch(src)).blob();
|
||||
if (blob.type === 'image/png') return blob;
|
||||
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
}
|
||||
|
||||
async function copyImage() {
|
||||
copyError = '';
|
||||
try {
|
||||
// Løftet gives videre frem for at blive ventet af først. Safari kræver, at
|
||||
// clipboard.write kaldes i selve klikket, og en hentning imellem gør
|
||||
// klikket til fortid — et ClipboardItem må gerne indeholde et løfte.
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch (e) {
|
||||
// Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre
|
||||
// end ingen knap: man indsætter noget andet og opdager det et andet sted.
|
||||
copyError = t('notes.copyFailed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole thing. */
|
||||
function accept(item) {
|
||||
const partial = partialTag();
|
||||
@@ -728,6 +853,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Står markøren i en punktliste eller en nummereret liste?
|
||||
*
|
||||
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
|
||||
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
|
||||
* ikke blive uenige om, hvad der er en liste.
|
||||
*/
|
||||
const inList = () =>
|
||||
document.queryCommandState('insertUnorderedList') ||
|
||||
document.queryCommandState('insertOrderedList');
|
||||
|
||||
function onkeydown(event) {
|
||||
// The suggestion list owns the arrows and return while it is open, the way
|
||||
// every other completion does. Escape closes it without choosing.
|
||||
@@ -756,6 +892,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
|
||||
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
|
||||
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
|
||||
if (event.key === 'Tab' && inList()) {
|
||||
event.preventDefault();
|
||||
apply(event.shiftKey ? 'outdent' : 'indent');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// A code block is the one place the browser keeps the caret inside on
|
||||
// Enter rather than starting a new block. Step out of it ourselves, or a
|
||||
@@ -1068,7 +1213,102 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
|
||||
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
|
||||
af netop det ark, det skal lægge sig over. -->
|
||||
{#if showing}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
||||
<img src={showing.src} alt={showing.alt} />
|
||||
<div class="tools">
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
</div>
|
||||
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (showing && e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
closeImage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
||||
står alene — det er hele grunden til at åbne det. */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgb(0 0 0 / 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s4);
|
||||
}
|
||||
|
||||
.shot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
|
||||
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
|
||||
passe sig ind i den. */
|
||||
.shot img {
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 8rem);
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tools button,
|
||||
.tools a {
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tools button:hover,
|
||||
.tools a:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.bad {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
|
||||
.page :global(img) {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -20,10 +20,36 @@
|
||||
let open = $state(false);
|
||||
let wrap = $state(null);
|
||||
|
||||
/**
|
||||
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
|
||||
*
|
||||
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
|
||||
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
|
||||
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
|
||||
* klippet. Der stod tre en halv linje tekst uden for skærmen.
|
||||
*
|
||||
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
|
||||
* kantafstand til højre for klokken — men så ville den her fil kende sine
|
||||
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
|
||||
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
|
||||
* skærmen med en kant i hver side.
|
||||
*/
|
||||
let below = $state(0);
|
||||
|
||||
function place() {
|
||||
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
if (open) place();
|
||||
}
|
||||
|
||||
const KINDS = {
|
||||
assigned: 'notif.assigned',
|
||||
'note.changed': 'notif.noteChanged',
|
||||
'note.shared': 'notif.noteShared',
|
||||
'project.shared': 'notif.projectShared',
|
||||
'note.mention': 'notif.mention',
|
||||
'daily.plan': 'notif.dailyPlan',
|
||||
'update.available': 'notif.update',
|
||||
@@ -71,6 +97,7 @@
|
||||
|
||||
<svelte:window
|
||||
onclick={outside}
|
||||
onresize={() => open && place()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && open) open = false;
|
||||
}}
|
||||
@@ -80,7 +107,7 @@
|
||||
<button
|
||||
class="bell"
|
||||
class:on={open}
|
||||
onclick={() => (open = !open)}
|
||||
onclick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={app.unread
|
||||
@@ -100,7 +127,7 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="panel" role="dialog" aria-label={t('notif.title')}>
|
||||
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
|
||||
<header>
|
||||
<span>{t('notif.title')}</span>
|
||||
{#if app.unread}
|
||||
@@ -192,6 +219,21 @@
|
||||
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
||||
}
|
||||
|
||||
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
|
||||
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
|
||||
toplinjens egen menuknap, så de to skifter form samtidig. */
|
||||
@media (max-width: 820px) {
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: calc(var(--below) + 6px);
|
||||
left: var(--s3);
|
||||
right: var(--s3);
|
||||
width: auto;
|
||||
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -171,6 +171,32 @@
|
||||
* took is said out loud, because a title that edits itself without a word is
|
||||
* worse than one that does nothing.
|
||||
*/
|
||||
/**
|
||||
* Lader et felt være lige så højt som sin tekst.
|
||||
*
|
||||
* `height: auto` først, og så `scrollHeight`: uden nulstillingen kan feltet kun
|
||||
* vokse, for scrollHeight på et felt, der allerede er for højt, er dets egen
|
||||
* højde.
|
||||
*/
|
||||
function grow(node) {
|
||||
const fit = () => {
|
||||
node.style.height = 'auto';
|
||||
node.style.height = `${node.scrollHeight}px`;
|
||||
};
|
||||
fit();
|
||||
node.addEventListener('input', fit);
|
||||
// Bredden skifter, når ruden gør — og en tekst, der fyldte to linjer, fylder
|
||||
// tre på en smallere skærm uden at der er rørt ved den.
|
||||
const watch = new ResizeObserver(fit);
|
||||
watch.observe(node);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('input', fit);
|
||||
watch.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function saveContent() {
|
||||
if (!task) return;
|
||||
const trimmed = content.trim();
|
||||
@@ -418,13 +444,27 @@
|
||||
|
||||
<div class="scroll">
|
||||
<div class="field">
|
||||
<input
|
||||
<!-- Et felt frem for en linje. En opgavetitel er tit længere end en telefon
|
||||
er bred — "tado°: Your Smart Thermostat needs new batteries" er en
|
||||
almindelig en — og et <input> kan ikke ombryde: det ruller, og så står
|
||||
halvdelen af overskriften uden for kanten. -->
|
||||
<textarea
|
||||
class="title"
|
||||
rows="1"
|
||||
use:focusOnMount
|
||||
use:grow
|
||||
bind:value={content}
|
||||
onblur={saveContent}
|
||||
onkeydown={(e) => {
|
||||
// Retur gemmer frem for at lave en ny linje. En titel er én linje,
|
||||
// uanset hvor mange den fylder på skærmen.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={t('detail.text')}
|
||||
/>
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -894,6 +934,16 @@
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
/* En beskrivelse er tit et klippet stykke mail med en URL i. Et <textarea>
|
||||
ombryder ved mellemrum, og en URL har ingen — så feltet rullede vandret,
|
||||
og på en telefon stod teksten forskudt med venstre kant klippet af. Det
|
||||
ser ikke ud som rulning; det ser ud som manglende bogstaver.
|
||||
|
||||
`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan
|
||||
stå på en linje alene, mens den anden bryder ved først givne lejlighed og
|
||||
delte "GarageRisteriet" midt i en stavelse. Se h1 på projektets side — den
|
||||
lektion er betalt én gang. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* The title is the one field that should not look like a field until you are in
|
||||
@@ -905,6 +955,10 @@
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding-left: 0;
|
||||
/* Højden sættes af `grow`, så håndtaget ville love noget, det ikke kan
|
||||
holde: trækker man i det, retter det næste bogstav det tilbage. */
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
|
||||
@@ -31,6 +31,7 @@ export const da = {
|
||||
'notif.assigned': '{name} gav dig en opgave',
|
||||
'notif.noteChanged': '{name} rettede en note',
|
||||
'notif.noteShared': '{name} delte en note med dig',
|
||||
'notif.projectShared': '{name} delte et projekt med dig',
|
||||
'notif.mention': '{name} nævnte dig i en note',
|
||||
'notif.dailyPlan': 'Dagens plan',
|
||||
'notif.update': 'Der er kommet en ny version',
|
||||
@@ -172,6 +173,10 @@ export const da = {
|
||||
'notes.copyCode': 'Kopiér',
|
||||
'notes.copied': 'Kopieret',
|
||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||
'notes.imageTitle': 'Billede',
|
||||
'notes.imageCopy': 'Kopiér billedet',
|
||||
'notes.imageCopied': 'Kopieret',
|
||||
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.code': 'Kode',
|
||||
'notes.showSource': 'Vis kilden (Markdown)',
|
||||
@@ -232,6 +237,9 @@ export const da = {
|
||||
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
||||
'net.reconnecting': 'Genopretter forbindelse…',
|
||||
'net.offline': 'Ingen forbindelse',
|
||||
'net.downTitle': 'Ingen forbindelse',
|
||||
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
|
||||
'net.downRetry': 'Prøv igen',
|
||||
'nav.doItToday': 'Gør det i dag',
|
||||
|
||||
// --- the sidebar's own sections ------------------------------------------------
|
||||
@@ -909,6 +917,8 @@ export const da = {
|
||||
'project.canView': 'Kan kun se',
|
||||
'project.invite': 'Inviter',
|
||||
'project.pickPerson': 'Vælg en person…',
|
||||
'project.leave': 'Forlad projektet',
|
||||
'project.leaveSure': 'Forlad {name}? Du mister adgangen, og kun ejeren kan lukke dig ind igen.',
|
||||
'project.roleForPicked': 'Rolle for den valgte',
|
||||
'project.shareWith': 'Del',
|
||||
'project.orByEmail': 'Eller en, der ikke er her endnu:',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const en = {
|
||||
'notif.assigned': '{name} gave you a task',
|
||||
'notif.noteChanged': '{name} edited a note',
|
||||
'notif.noteShared': '{name} shared a note with you',
|
||||
'notif.projectShared': '{name} shared a project with you',
|
||||
'notif.mention': '{name} mentioned you in a note',
|
||||
'notif.dailyPlan': "Today's plan",
|
||||
'notif.update': 'A new version is out',
|
||||
@@ -169,6 +170,10 @@ export const en = {
|
||||
'notes.copyCode': 'Copy',
|
||||
'notes.copied': 'Copied',
|
||||
'notes.copyFailed': 'Could not copy',
|
||||
'notes.imageTitle': 'Image',
|
||||
'notes.imageCopy': 'Copy the image',
|
||||
'notes.imageCopied': 'Copied',
|
||||
'notes.imageOpen': 'Open in a new tab',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.code': 'Code',
|
||||
'notes.showSource': 'Show the source (Markdown)',
|
||||
@@ -229,6 +234,9 @@ export const en = {
|
||||
'nav.offlineHint': 'Changes from other people are not showing right now',
|
||||
'net.reconnecting': 'Reconnecting…',
|
||||
'net.offline': 'No connection',
|
||||
'net.downTitle': 'No connection',
|
||||
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
|
||||
'net.downRetry': 'Try again',
|
||||
'nav.doItToday': 'Do it today',
|
||||
|
||||
// --- the sidebar's own sections ------------------------------------------------
|
||||
@@ -903,6 +911,8 @@ export const en = {
|
||||
'project.canView': 'Can only view',
|
||||
'project.invite': 'Invite',
|
||||
'project.pickPerson': 'Pick somebody…',
|
||||
'project.leave': 'Leave the project',
|
||||
'project.leaveSure': 'Leave {name}? You lose access, and only its owner can let you back in.',
|
||||
'project.roleForPicked': 'Role for the person picked',
|
||||
'project.shareWith': 'Share',
|
||||
'project.orByEmail': 'Or somebody who is not here yet:',
|
||||
|
||||
+44
-8
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
|
||||
/** Whether an element holds blocks, in which case reading it as one line loses them. */
|
||||
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
|
||||
|
||||
/**
|
||||
* Teksten i et blok, med linjeskiftene i behold.
|
||||
*
|
||||
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
|
||||
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
|
||||
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
|
||||
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
|
||||
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
|
||||
* tiden.
|
||||
*
|
||||
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
|
||||
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
|
||||
* gemning.
|
||||
*
|
||||
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
|
||||
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
|
||||
* der allerede har tabt linjeskiftet.
|
||||
*/
|
||||
export function blockText(node) {
|
||||
let out = '';
|
||||
for (const n of node.childNodes) {
|
||||
if (n.nodeType !== Node.ELEMENT_NODE) {
|
||||
out += n.textContent ?? '';
|
||||
continue;
|
||||
}
|
||||
if (n.classList?.contains('copy')) continue;
|
||||
if (n.tagName === 'BR') {
|
||||
out += '\n';
|
||||
continue;
|
||||
}
|
||||
out += blockText(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(root) {
|
||||
const lines = [];
|
||||
|
||||
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
|
||||
// else and an indent does not.
|
||||
const lang = child.getAttribute('data-lang') ?? '';
|
||||
emit('```' + lang);
|
||||
// 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('');
|
||||
const code = blockText(child);
|
||||
for (const l of code.replace(/\n$/, '').split('\n')) {
|
||||
emit(l);
|
||||
}
|
||||
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
|
||||
const pad = ' '.repeat(depth);
|
||||
|
||||
for (const li of el.children) {
|
||||
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
|
||||
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
|
||||
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
|
||||
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
|
||||
if (li.tagName === 'UL' || li.tagName === 'OL') {
|
||||
list(li, depth + 1, emit);
|
||||
continue;
|
||||
}
|
||||
if (li.tagName !== 'LI') continue;
|
||||
|
||||
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
||||
|
||||
@@ -29,6 +29,20 @@ class AppState {
|
||||
people = $state([]);
|
||||
tasks = $state([]);
|
||||
loading = $state(true);
|
||||
|
||||
/**
|
||||
* True when the boot never reached the server — as opposed to reaching it and
|
||||
* being told the session is gone.
|
||||
*
|
||||
* The two are indistinguishable from `user === null`, and they are opposite
|
||||
* things to the person looking at the screen: one means "log ind", the other
|
||||
* means "der er intet at logge ind på herfra, og et kodeord hjælper ikke". A
|
||||
* total outage used to show the login screen, so the app blamed the user's
|
||||
* session for the network being down — and a login page that cannot possibly
|
||||
* work is worse than an error, because it looks like it might.
|
||||
*/
|
||||
unreachable = $state(false);
|
||||
|
||||
/** Transient messages: a failed save, a rolled-back change. */
|
||||
toasts = $state([]);
|
||||
connected = $state(false);
|
||||
@@ -96,6 +110,7 @@ class AppState {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.user = await api.me();
|
||||
this.unreachable = false;
|
||||
// The interface's language follows the account, so it is set the moment
|
||||
// the session is known — before anything has drawn a string.
|
||||
i18n.set(this.user.locale);
|
||||
@@ -111,6 +126,10 @@ class AppState {
|
||||
this.loadNotifications();
|
||||
} catch (e) {
|
||||
this.user = null;
|
||||
// `offline` is the one code the request layer makes up itself: it is what
|
||||
// is thrown when the retry window ran out without any status ever
|
||||
// arriving. A 401 is the server answering, and that is a real sign-out.
|
||||
this.unreachable = e?.code === 'offline';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import NoConnection from '$lib/components/NoConnection.svelte';
|
||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { setBadge } from '$lib/badge.js';
|
||||
@@ -166,6 +167,10 @@
|
||||
<div class="booting"></div>
|
||||
{:else if standalone}
|
||||
{@render children()}
|
||||
{:else if app.unreachable}
|
||||
<!-- Ikke login-skærmen: en total udfald er ikke en udløbet session, og et
|
||||
kodeord hjælper ikke mod en manglende forbindelse. -->
|
||||
<NoConnection />
|
||||
{:else if !app.user}
|
||||
<SignIn />
|
||||
{:else}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
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 { onMount, untrack } from 'svelte';
|
||||
import { afterNavigate, 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';
|
||||
@@ -483,23 +484,95 @@
|
||||
load(query);
|
||||
});
|
||||
|
||||
// Arriving from somewhere that names a note — a task's panel, a project's page —
|
||||
// opens it rather than dropping the person on a list to find it again.
|
||||
let asked = $derived($page.url.searchParams.get('note'));
|
||||
/**
|
||||
* En søgning, man kommer med.
|
||||
*
|
||||
* ⌘K sender `?note=…&q=…`, så listen ved siden af den åbne note er de andre
|
||||
* træffere frem for den almindelige liste.
|
||||
*
|
||||
* Læst, aldrig skrevet. Den første udgave skrev også søgningen tilbage i
|
||||
* adressen ved hvert tastetryk, så den kunne gemmes og deles — og det kostede
|
||||
* bogstaver. `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes,
|
||||
* så to hurtige tastetryk gav en effekt, der læste den *forrige* adresse, fandt
|
||||
* den uenig med det skrevne, og skrev den gamle værdi tilbage i feltet. Man
|
||||
* skrev "verd", og der stod "nde". Målt på en skærmoptagelse, ikke gættet.
|
||||
*
|
||||
* En markør, der skal holde to retninger enige, er en markør, der taber
|
||||
* kapløbet et sted. Adressen skrives derfor kun af navigation, og det her er
|
||||
* den eneste, der læser den.
|
||||
*/
|
||||
let cameWith = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!asked || selectedId === asked) return;
|
||||
const found = notes.find((n) => n.id === asked);
|
||||
if (found) open(found);
|
||||
else api.note(asked).then(open).catch(() => {});
|
||||
const inURL = $page.url.searchParams.get('q') ?? '';
|
||||
if (inURL === untrack(() => cameWith)) return;
|
||||
cameWith = inURL;
|
||||
draftQuery = inURL;
|
||||
query = inURL;
|
||||
});
|
||||
|
||||
/**
|
||||
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
|
||||
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
|
||||
* den igen.
|
||||
*
|
||||
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
|
||||
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
|
||||
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
|
||||
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
|
||||
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
|
||||
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
|
||||
* sprang tilbage til den første.
|
||||
*
|
||||
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
|
||||
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
|
||||
* ved hver ændring.
|
||||
*/
|
||||
function openFromURL(url) {
|
||||
const id = url?.searchParams.get('note');
|
||||
if (!id || id === selectedId) return;
|
||||
const found = notes.find((n) => n.id === id);
|
||||
if (found) open(found);
|
||||
else api.note(id).then(open).catch(() => {});
|
||||
}
|
||||
|
||||
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
|
||||
// den anden.
|
||||
//
|
||||
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
|
||||
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
|
||||
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
|
||||
// eller fulgt udefra åbnede så ingenting.
|
||||
//
|
||||
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
|
||||
// programmet, monteres siden ikke om.
|
||||
onMount(() => openFromURL($page.url));
|
||||
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
|
||||
|
||||
/**
|
||||
* Hvilken hentning der gælder.
|
||||
*
|
||||
* To søgninger kan være undervejs på én gang, og den langsomste kan lande
|
||||
* sidst. Uden det her betød det, at listen viste svaret på et spørgsmål, der
|
||||
* ikke længere blev stillet — på optagelsen stod resultaterne for "verdande" i
|
||||
* listen, efter feltet var tømt. Pausen på 250 ms gjorde det sjældnere og kunne
|
||||
* ikke gøre det umuligt.
|
||||
*/
|
||||
let newest = 0;
|
||||
|
||||
async function load(q) {
|
||||
const mine = ++newest;
|
||||
try {
|
||||
const params = q ? { q } : showArchive ? { archived: '1' } : {};
|
||||
notes = (await api.notes(params)).notes ?? [];
|
||||
const got = (await api.notes(params)).notes ?? [];
|
||||
// Et svar, der er blevet overhalet, kastes væk. Det er ikke forkert —
|
||||
// det er bare ikke længere svaret på det, der blev spurgt om.
|
||||
if (mine !== newest) return;
|
||||
notes = got;
|
||||
status = 'ready';
|
||||
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
|
||||
} catch (e) {
|
||||
if (mine !== newest) return;
|
||||
status = 'failed';
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
@@ -516,7 +589,17 @@
|
||||
* 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) {
|
||||
/**
|
||||
* `whole` siger, at noten allerede er hel, og at der ikke skal hentes.
|
||||
*
|
||||
* En tom krop bruges ellers som tegn på, at det her er en listerække — serveren
|
||||
* tømmer `body` på dem med vilje. Men en *nyoprettet* note er også tom, og den
|
||||
* er hel. Uden det her blev den lukket ud som en halv note: editoren blev pillet
|
||||
* af skærmen, noten hentet igen, og editoren sat op på ny. I det hul står
|
||||
* `selected` som null, og `save()` går tavst tilbage uden at gemme — så tekst
|
||||
* skrevet der forsvandt sporløst.
|
||||
*/
|
||||
async function open(note, { whole = false } = {}) {
|
||||
clearTimeout(timer);
|
||||
if (!note) {
|
||||
selectedId = null;
|
||||
@@ -532,9 +615,9 @@
|
||||
// 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) {
|
||||
if (whole || note.body) {
|
||||
selected = note;
|
||||
draft = note.body;
|
||||
draft = note.body ?? '';
|
||||
return;
|
||||
}
|
||||
selected = null;
|
||||
@@ -646,7 +729,8 @@
|
||||
try {
|
||||
const note = await api.createNote({ body: '' });
|
||||
notes = [note, ...notes];
|
||||
open(note);
|
||||
// Svaret er hele noten, så den skal ikke hentes igen — se open().
|
||||
await open(note, { whole: true });
|
||||
document.querySelector('.editor textarea')?.focus();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
|
||||
@@ -551,6 +551,21 @@
|
||||
if (!inviteError) inviteEmail = '';
|
||||
}
|
||||
|
||||
// Udgangen. Man bliver lukket ind i et projekt uden at blive spurgt, så der skal
|
||||
// være en vej ud, der ikke går gennem at bede ejeren om det.
|
||||
async function leave() {
|
||||
if (!confirm(t('project.leaveSure', { name: project.name }))) return;
|
||||
try {
|
||||
await api.removeMember(project.id, app.user.id);
|
||||
// Væk fra en side, man ikke længere må se: bliver man stående, er det
|
||||
// næste, siden gør, at hente den og få 404.
|
||||
await goto('/');
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw(invite) {
|
||||
try {
|
||||
await api.withdrawInvite(project.id, invite.id);
|
||||
@@ -826,6 +841,12 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Kun for dem, der er lukket ind. Ejeren kan ikke gå fra sit eget
|
||||
projekt — det ville være en overdragelse, ikke en udgang. -->
|
||||
{#if !isOwner}
|
||||
<button class="leave" onclick={leave}>{t('project.leave')}</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
|
||||
deles ved at stave en e-mailadresse, altså ved at kende adressen på
|
||||
@@ -1501,6 +1522,17 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* I fareskrift og alene: det er den eneste knap i panelet, der handler om en
|
||||
selv, og den skal ikke kunne forveksles med at fjerne en anden. */
|
||||
.leave {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
color: var(--danger);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.byemail-hint {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
|
||||
Reference in New Issue
Block a user