Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0110a86858 | ||
|
|
c868be8f89 | ||
|
|
145fefea02 | ||
|
|
5d911d40fe | ||
|
|
9788bbaaf8 | ||
|
|
f68d6a8c15 | ||
|
|
ff0447317b | ||
|
|
a2f6e9bda3 | ||
|
|
1bbd36c79b | ||
|
|
54359f8d68 | ||
|
|
f400705bb7 | ||
|
|
0dc4c2f9b8 | ||
|
|
7f09b35db5 | ||
|
|
0f4048d8f2 |
+18
-10
@@ -1,13 +1,21 @@
|
|||||||
name: Documentation
|
name: Documentation
|
||||||
|
|
||||||
# The docs are built on every push so a broken build is caught immediately, but
|
# Built on every push, and published from main.
|
||||||
# only published once GitHub Pages has been enabled for the repository. Until the
|
|
||||||
# owner decides to publish, this job builds and stops.
|
|
||||||
#
|
#
|
||||||
# And on pull requests, where the build is the only check there is: `mkdocs build
|
# The publishing used to wait for a manual dispatch, with a note saying it would
|
||||||
# --strict` fails on a link to a page that does not exist, and building only after
|
# run "once Pages is enabled". Pages was enabled — and the condition was never
|
||||||
# the merge means finding that out on `main`. Nothing is published from a pull
|
# changed, so the site was built forty times and published none of them. What it
|
||||||
# request — the `deploy` job below runs on a manual dispatch and nothing else.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -54,9 +62,9 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Only runs once Pages is enabled. Until then the build above still guards
|
# From main, and by hand. Never from a pull request — a fork could otherwise
|
||||||
# against a broken docs tree.
|
# publish over the site by opening one.
|
||||||
if: github.event_name == 'workflow_dispatch'
|
if: github.event_name != 'pull_request'
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deploy.outputs.page_url }}
|
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
|
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
|
||||||
# side, der linker til sig selv, hjælper ingen.
|
# 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
|
- id: notes
|
||||||
run: |
|
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'
|
echo 'body<<VERDANDE_EOF'
|
||||||
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
printf '%s\n' "$body"
|
||||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
|
||||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
|
|
||||||
echo VERDANDE_EOF
|
echo VERDANDE_EOF
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$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
|
All six phases are built. See [What works today](#what-works-today) for what is
|
||||||
tested and what is not.
|
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
|
## 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
|
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
|
`[[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.
|
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
|
- **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.
|
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:
|
- **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 |
|
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||||
| End-to-end smoke tests (Playwright) | Done |
|
| End-to-end smoke tests (Playwright) | Done |
|
||||||
| Documentation, landing page, licence | 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
|
**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,
|
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
|
it is a document that can run script, and a note is not a place to run somebody
|
||||||
else's.
|
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
|
### Code blocks
|
||||||
|
|
||||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
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. A word in the title is what the note is about; the same word once in a long
|
||||||
body is a mention.
|
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
|
## Putting notes away
|
||||||
|
|
||||||
Two different things, and the difference matters:
|
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 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 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 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 gave you a task | Only on the change, and only to the person who got it |
|
||||||
| Somebody commented | |
|
| Somebody commented | |
|
||||||
|
|||||||
+12
-1
@@ -2379,8 +2379,19 @@ paths:
|
|||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
delete:
|
delete:
|
||||||
tags: [projects]
|
tags: [projects]
|
||||||
summary: Remove somebody from a project. Its owner only.
|
summary: Remove somebody from a project — its owner, or yourself.
|
||||||
description: |
|
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
|
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
|
no longer see the project — which is why changing a role is a PATCH and
|
||||||
not a remove-and-reinvite.
|
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
|
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.
|
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,
|
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,
|
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
|
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
|
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||||
// it — folded into one line per note while it is unread.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"added": true,
|
"added": true,
|
||||||
// Personen sendes med retur, fordi fladen kan have delt med en adresse
|
// 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)
|
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) {
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
userID := chi.URLParam(r, "userID")
|
userID := chi.URLParam(r, "userID")
|
||||||
|
me := userFrom(r.Context())
|
||||||
|
|
||||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, r, "project owner", err)
|
s.storeError(w, r, "project owner", err)
|
||||||
return
|
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 {
|
if userID == owner {
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeConflict,
|
||||||
"the owner cannot be removed from their own project")
|
"the owner cannot be removed from their own project")
|
||||||
|
|||||||
@@ -627,3 +627,98 @@ func hasPerson(raw any, id string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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("/", s.handleGetProject)
|
||||||
r.Get("/sections", s.handleListSections)
|
r.Get("/sections", s.handleListSections)
|
||||||
r.Get("/members", s.handleListMembers)
|
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.Get("/activity", s.handleActivity)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
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.Post("/invites", s.handleInvite)
|
||||||
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+58
-11
@@ -1326,6 +1326,64 @@
|
|||||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||||
</p>
|
</p>
|
||||||
<ul class="lately">
|
<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>
|
<li>
|
||||||
<span class="ver">0.42</span>
|
<span class="ver">0.42</span>
|
||||||
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
||||||
@@ -1362,17 +1420,6 @@
|
|||||||
body to move it. A single note can be shared with one person, and a
|
body to move it. A single note can be shared with one person, and a
|
||||||
<code>[[note]]</code> is a link you follow inline.
|
<code>[[note]]</code> is a link you follow inline.
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -4597,5 +4597,227 @@ 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
|
// 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.
|
// 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å "Gemt", før den næste note laves. Uden det klikkes "Ny note",
|
||||||
|
// mens den forrige stadig er undervejs, og listen når ikke at få den med — det
|
||||||
|
// fejlede i CI én gang ud af tre kørsler, og bestod hver gang her på maskinen.
|
||||||
|
// En prøve, der er hurtigere end programmet, måler maskinen den kører på.
|
||||||
|
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const body = page.getByLabel('Notens tekst');
|
||||||
|
await body.fill(`${title}\n\nnoget om den`);
|
||||||
|
await body.blur();
|
||||||
|
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
|
||||||
|
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 adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente
|
||||||
|
// en søgning tilbage, man selv havde ryddet væk.
|
||||||
|
await page.getByPlaceholder('Søg i noter').fill('');
|
||||||
|
await expect(page).not.toHaveURL(/[?&]q=/);
|
||||||
|
await expect(list.getByText('Egeskov')).toBeVisible();
|
||||||
|
|
||||||
|
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([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,7 +125,13 @@
|
|||||||
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
|
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
|
||||||
// op, den ER stedet.
|
// op, den ER stedet.
|
||||||
if (item.kind === 'setting') return goto(item.id);
|
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}`);
|
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -640,9 +640,69 @@
|
|||||||
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||||
return;
|
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();
|
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. */
|
/** Replaces the half-typed tag with the whole thing. */
|
||||||
function accept(item) {
|
function accept(item) {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
@@ -1068,7 +1128,102 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<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 {
|
.wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
assigned: 'notif.assigned',
|
assigned: 'notif.assigned',
|
||||||
'note.changed': 'notif.noteChanged',
|
'note.changed': 'notif.noteChanged',
|
||||||
'note.shared': 'notif.noteShared',
|
'note.shared': 'notif.noteShared',
|
||||||
|
'project.shared': 'notif.projectShared',
|
||||||
'note.mention': 'notif.mention',
|
'note.mention': 'notif.mention',
|
||||||
'daily.plan': 'notif.dailyPlan',
|
'daily.plan': 'notif.dailyPlan',
|
||||||
'update.available': 'notif.update',
|
'update.available': 'notif.update',
|
||||||
|
|||||||
@@ -171,6 +171,32 @@
|
|||||||
* took is said out loud, because a title that edits itself without a word is
|
* took is said out loud, because a title that edits itself without a word is
|
||||||
* worse than one that does nothing.
|
* 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() {
|
async function saveContent() {
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
@@ -418,13 +444,27 @@
|
|||||||
|
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<div class="field">
|
<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"
|
class="title"
|
||||||
|
rows="1"
|
||||||
use:focusOnMount
|
use:focusOnMount
|
||||||
|
use:grow
|
||||||
bind:value={content}
|
bind:value={content}
|
||||||
onblur={saveContent}
|
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')}
|
aria-label={t('detail.text')}
|
||||||
/>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -894,6 +934,16 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1.5;
|
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
|
/* The title is the one field that should not look like a field until you are in
|
||||||
@@ -905,6 +955,10 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
padding-left: 0;
|
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 {
|
.title:hover {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const da = {
|
|||||||
'notif.assigned': '{name} gav dig en opgave',
|
'notif.assigned': '{name} gav dig en opgave',
|
||||||
'notif.noteChanged': '{name} rettede en note',
|
'notif.noteChanged': '{name} rettede en note',
|
||||||
'notif.noteShared': '{name} delte en note med dig',
|
'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.mention': '{name} nævnte dig i en note',
|
||||||
'notif.dailyPlan': 'Dagens plan',
|
'notif.dailyPlan': 'Dagens plan',
|
||||||
'notif.update': 'Der er kommet en ny version',
|
'notif.update': 'Der er kommet en ny version',
|
||||||
@@ -172,6 +173,10 @@ export const da = {
|
|||||||
'notes.copyCode': 'Kopiér',
|
'notes.copyCode': 'Kopiér',
|
||||||
'notes.copied': 'Kopieret',
|
'notes.copied': 'Kopieret',
|
||||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
'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.strike': 'Gennemstreget',
|
||||||
'notes.code': 'Kode',
|
'notes.code': 'Kode',
|
||||||
'notes.showSource': 'Vis kilden (Markdown)',
|
'notes.showSource': 'Vis kilden (Markdown)',
|
||||||
@@ -909,6 +914,8 @@ export const da = {
|
|||||||
'project.canView': 'Kan kun se',
|
'project.canView': 'Kan kun se',
|
||||||
'project.invite': 'Inviter',
|
'project.invite': 'Inviter',
|
||||||
'project.pickPerson': 'Vælg en person…',
|
'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.roleForPicked': 'Rolle for den valgte',
|
||||||
'project.shareWith': 'Del',
|
'project.shareWith': 'Del',
|
||||||
'project.orByEmail': 'Eller en, der ikke er her endnu:',
|
'project.orByEmail': 'Eller en, der ikke er her endnu:',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const en = {
|
|||||||
'notif.assigned': '{name} gave you a task',
|
'notif.assigned': '{name} gave you a task',
|
||||||
'notif.noteChanged': '{name} edited a note',
|
'notif.noteChanged': '{name} edited a note',
|
||||||
'notif.noteShared': '{name} shared a note with you',
|
'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.mention': '{name} mentioned you in a note',
|
||||||
'notif.dailyPlan': "Today's plan",
|
'notif.dailyPlan': "Today's plan",
|
||||||
'notif.update': 'A new version is out',
|
'notif.update': 'A new version is out',
|
||||||
@@ -169,6 +170,10 @@ export const en = {
|
|||||||
'notes.copyCode': 'Copy',
|
'notes.copyCode': 'Copy',
|
||||||
'notes.copied': 'Copied',
|
'notes.copied': 'Copied',
|
||||||
'notes.copyFailed': 'Could not copy',
|
'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.strike': 'Strikethrough',
|
||||||
'notes.code': 'Code',
|
'notes.code': 'Code',
|
||||||
'notes.showSource': 'Show the source (Markdown)',
|
'notes.showSource': 'Show the source (Markdown)',
|
||||||
@@ -903,6 +908,8 @@ export const en = {
|
|||||||
'project.canView': 'Can only view',
|
'project.canView': 'Can only view',
|
||||||
'project.invite': 'Invite',
|
'project.invite': 'Invite',
|
||||||
'project.pickPerson': 'Pick somebody…',
|
'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.roleForPicked': 'Role for the person picked',
|
||||||
'project.shareWith': 'Share',
|
'project.shareWith': 'Share',
|
||||||
'project.orByEmail': 'Or somebody who is not here yet:',
|
'project.orByEmail': 'Or somebody who is not here yet:',
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { untrack } from 'svelte';
|
||||||
|
import { goto, replaceState } from '$app/navigation';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -483,6 +484,49 @@
|
|||||||
load(query);
|
load(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En søgning, man kommer med, og en søgning, man kan gå videre med.
|
||||||
|
*
|
||||||
|
* ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede
|
||||||
|
* den noten på en side, hvis liste stod uændret. Så var de andre træffere væk,
|
||||||
|
* og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg
|
||||||
|
* får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg
|
||||||
|
* tilbage til den normale liste."
|
||||||
|
*
|
||||||
|
* Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre
|
||||||
|
* træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og
|
||||||
|
* deles — og frem for alt ikke lyver, når man har tømt feltet.
|
||||||
|
*
|
||||||
|
* `settled` er det, de to retninger er blevet enige om — og den læses med
|
||||||
|
* `untrack`, hvilket er hele forskellen på at virke og ikke.
|
||||||
|
*
|
||||||
|
* Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og
|
||||||
|
* `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor
|
||||||
|
* den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og
|
||||||
|
* skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen.
|
||||||
|
* Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig
|
||||||
|
* på den fælles markør.
|
||||||
|
*/
|
||||||
|
let settled = $state(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const inURL = $page.url.searchParams.get('q') ?? '';
|
||||||
|
if (inURL === untrack(() => settled)) return;
|
||||||
|
settled = inURL;
|
||||||
|
draftQuery = inURL;
|
||||||
|
query = inURL;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const now = query;
|
||||||
|
if (now === untrack(() => settled)) return;
|
||||||
|
settled = now;
|
||||||
|
const url = new URL(untrack(() => $page.url));
|
||||||
|
if (now) url.searchParams.set('q', now);
|
||||||
|
else url.searchParams.delete('q');
|
||||||
|
replaceState(url, {});
|
||||||
|
});
|
||||||
|
|
||||||
// Arriving from somewhere that names a note — a task's panel, a project's page —
|
// 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.
|
// opens it rather than dropping the person on a list to find it again.
|
||||||
let asked = $derived($page.url.searchParams.get('note'));
|
let asked = $derived($page.url.searchParams.get('note'));
|
||||||
|
|||||||
@@ -551,6 +551,21 @@
|
|||||||
if (!inviteError) inviteEmail = '';
|
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) {
|
async function withdraw(invite) {
|
||||||
try {
|
try {
|
||||||
await api.withdrawInvite(project.id, invite.id);
|
await api.withdrawInvite(project.id, invite.id);
|
||||||
@@ -826,6 +841,12 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/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}
|
{#if isOwner}
|
||||||
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
|
<!-- 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å
|
deles ved at stave en e-mailadresse, altså ved at kende adressen på
|
||||||
@@ -1501,6 +1522,17 @@
|
|||||||
flex-wrap: wrap;
|
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 {
|
.byemail-hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
Reference in New Issue
Block a user