Compare commits
37
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 | ||
|
|
9b21b3eac5 | ||
|
|
a39f655565 | ||
|
|
7e981354c2 | ||
|
|
1f0a43ac90 | ||
|
|
61f8887319 | ||
|
|
72637a153a |
+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,
|
||||
|
||||
-1537
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
|
||||
| `GET` `POST` | `/projects/{id}/sections` | |
|
||||
| `PATCH` `DELETE` | `/sections/{id}` | |
|
||||
| `GET` | `/projects/{id}/members` | |
|
||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
|
||||
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
|
||||
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
|
||||
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
|
||||
| `GET` | `/projects/{id}/activity` | |
|
||||
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||
|
||||
+9
-2
@@ -26,6 +26,10 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||
The calendar draws each task to its length — drag the foot to resize, the body
|
||||
to move it.
|
||||
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
|
||||
settings themselves, by what they *do* rather than only by what they are called,
|
||||
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
|
||||
settings pages carry the same field at the top of their own list.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
||||
single note with a person — by name, by email address, or by writing `@Sofie` in
|
||||
the text — or file it in a project; `#project` and `[[another note]]` are links,
|
||||
@@ -36,8 +40,11 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
it is due.
|
||||
- **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** with owner, editor and viewer roles. Anyone outside your instance
|
||||
joins through an invite link.
|
||||
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
|
||||
account here you pick by name — the accounts on an instance are a closed,
|
||||
invited set, so they are an address book rather than a directory to protect.
|
||||
Anyone outside your instance joins through an invite link, and the invitation
|
||||
stays visible until they arrive, so it does not get sent twice.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
- **[Told when it matters](notifications.md).** A bell for what other people did to
|
||||
what you share, pushed to your devices and counted on the app's icon in the Dock.
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
+76
-5
@@ -2307,7 +2307,15 @@ paths:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: Who the project is shared with.
|
||||
summary: Who the project is shared with, who can be added, and who has been invited.
|
||||
description: >
|
||||
Three lists in one answer, so the share panel opens complete in a single
|
||||
round-trip. `candidates` is everybody with an account here who is not
|
||||
already on the project — the instance's accounts are a closed, invited set,
|
||||
so listing them to each other is the address book the feature needs, not a
|
||||
disclosure. `pending` is the invitations sent to people who have no account
|
||||
yet: without them an invitation sent yesterday is in neither of the other
|
||||
two lists, and gets sent a second time.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
@@ -2319,6 +2327,17 @@ paths:
|
||||
members:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Member" }
|
||||
candidates:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Person" }
|
||||
pending:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
role: { type: string, enum: [editor, viewer] }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/projects/{projectID}/members/{userID}:
|
||||
@@ -2360,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.
|
||||
@@ -2384,13 +2414,32 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email, role]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
user_id:
|
||||
type: string
|
||||
description: >
|
||||
Somebody who already has an account here, picked from the
|
||||
candidates in GET /members. One of user_id or email.
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
description: >
|
||||
An address. If it belongs to an account they are added at
|
||||
once; otherwise an invitation is sent.
|
||||
role: { type: string, enum: [editor, viewer] }
|
||||
responses:
|
||||
"200":
|
||||
description: Added straight away — the person already had an account.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
added: { type: boolean }
|
||||
user: { $ref: "#/components/schemas/Person" }
|
||||
role: { type: string, enum: [editor, viewer] }
|
||||
"201":
|
||||
description: Created
|
||||
description: Invited. No account yet, so a link was made.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -2399,8 +2448,30 @@ paths:
|
||||
link: { type: string }
|
||||
emailed: { type: boolean }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/{projectID}/invites/{inviteID}:
|
||||
delete:
|
||||
tags: [projects]
|
||||
summary: Withdraw an invitation to a project before it is used. Its owner only.
|
||||
description: >
|
||||
The only way back. Only the token's hash is stored, so a link sent to the
|
||||
wrong address cannot be looked up and invalidated — only the row it belongs
|
||||
to can.
|
||||
parameters:
|
||||
- name: projectID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: inviteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"204": { description: Withdrawn }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/projects/{projectID}/activity:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
|
||||
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
|
||||
anywhere: the first person to open a fresh instance creates the administrator
|
||||
account, and after that people arrive by invite link.
|
||||
|
||||
## Being let into a project
|
||||
|
||||
Two different situations, one panel — **the project's ⋯ menu → Share**.
|
||||
|
||||
Somebody who **already has an account here** you pick from a list by name. You do
|
||||
not need to know their email address to share a project with a colleague whose
|
||||
name you know. The accounts on an instance are a closed set — there is no open
|
||||
signup, every one of them was invited — so listing them to each other is the
|
||||
address book the feature needs, not a disclosure.
|
||||
|
||||
Somebody who is **not here yet** you invite by email. If the address turns out to
|
||||
belong to an account it becomes an ordinary share straight away; otherwise it
|
||||
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
|
||||
is to send it a second time. The second link works exactly as well as the first,
|
||||
and then there are two to keep track of.
|
||||
|
||||
## A code on top of it
|
||||
|
||||
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -452,22 +452,100 @@ type memberJSON struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
|
||||
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
|
||||
// dukker op.
|
||||
type projectInviteJSON struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleListMembers is everything the share panel needs, in one answer: who is on
|
||||
// the project, who can be added, and who has been invited and not arrived.
|
||||
//
|
||||
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
|
||||
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
|
||||
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
|
||||
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
|
||||
//
|
||||
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
|
||||
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
|
||||
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
|
||||
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
|
||||
members, err := s.db.ListMembers(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list members", err)
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
already := map[string]bool{}
|
||||
for _, m := range members {
|
||||
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
||||
already[m.UserID] = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"members": out})
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
already[project.OwnerID] = true
|
||||
|
||||
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "share candidates", err)
|
||||
return
|
||||
}
|
||||
candidates := make([]personJSON, 0, len(people))
|
||||
for _, p := range people {
|
||||
if already[p.ID] {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
|
||||
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list project invites", err)
|
||||
return
|
||||
}
|
||||
pending := make([]projectInviteJSON, 0, len(invites))
|
||||
for _, i := range invites {
|
||||
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"members": out,
|
||||
"candidates": candidates,
|
||||
"pending": pending,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
|
||||
//
|
||||
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
|
||||
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
|
||||
// udløber, med et link, der virker hele tiden.
|
||||
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
|
||||
if err != nil {
|
||||
s.storeError(w, r, "delete project invite", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type inviteRequest struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
// UserID er den, man har peget på i listen. Email er den, man kender adressen
|
||||
// på — og den er også den, der virker, når der ingen konto er endnu.
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type inviteResponse struct {
|
||||
@@ -478,8 +556,14 @@ type inviteResponse struct {
|
||||
Emailed bool `json:"emailed"`
|
||||
}
|
||||
|
||||
// handleInvite shares a project with somebody. An existing user is added directly;
|
||||
// anybody else gets a link that creates their account and their membership at once.
|
||||
// handleInvite shares a project with somebody.
|
||||
//
|
||||
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
|
||||
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
|
||||
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
|
||||
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
|
||||
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
|
||||
// kontoen og medlemskabet på én gang.
|
||||
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
var req inviteRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||
return
|
||||
}
|
||||
|
||||
email := store.NormalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
if req.UserID == "" && email == "" {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||
return
|
||||
}
|
||||
if req.UserID == "" && !strings.Contains(email, "@") {
|
||||
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||
return
|
||||
}
|
||||
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Somebody who already has an account is added straight away: sending them
|
||||
// through a signup form they cannot complete would be a dead end.
|
||||
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||
if existing.ID == project.OwnerID {
|
||||
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
|
||||
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
|
||||
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
|
||||
if req.UserID == "" {
|
||||
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||
req.UserID = existing.ID
|
||||
}
|
||||
}
|
||||
|
||||
if req.UserID != "" {
|
||||
if req.UserID == project.OwnerID {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
||||
return
|
||||
}
|
||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
||||
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
|
||||
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
|
||||
// tom plads, ingen kan fjerne.
|
||||
person, err := s.db.PersonByID(r.Context(), req.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||
return
|
||||
}
|
||||
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
|
||||
s.internal(w, r, "add member", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"added": true})
|
||||
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
|
||||
// og derfor ikke selv ved, hvem den ramte.
|
||||
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
||||
"role": string(role),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Et andet link til den samme indbakke hjælper ingen: det første virker
|
||||
// stadig, og nu er der to at holde styr på og to at trække tilbage.
|
||||
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -590,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")
|
||||
|
||||
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
|
||||
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
|
||||
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
|
||||
// beskytte ved at lade være med at vise dem.
|
||||
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
anden, 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": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
// Først står de i listen over dem, der kan tilføjes.
|
||||
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if !hasPerson(before["candidates"], anden.ID) {
|
||||
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
|
||||
}
|
||||
|
||||
// Delt ved at pege, ikke ved at stave.
|
||||
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": anden.ID, "role": "editor"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, added)
|
||||
}
|
||||
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
|
||||
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
|
||||
t.Errorf("user = %v, want Anden", added["user"])
|
||||
}
|
||||
|
||||
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
|
||||
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
|
||||
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if hasPerson(after["candidates"], anden.ID) {
|
||||
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
|
||||
}
|
||||
found := false
|
||||
for _, raw := range after["members"].([]any) {
|
||||
if m := raw.(map[string]any); m["user_id"] == anden.ID {
|
||||
found = true
|
||||
if m["role"] != "editor" {
|
||||
t.Errorf("role = %v, want editor", m["role"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Anden blev ikke medlem: %v", after["members"])
|
||||
}
|
||||
}
|
||||
|
||||
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
|
||||
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(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"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||
if hasPerson(body["candidates"], me.ID) {
|
||||
t.Errorf("ejeren står som kandidat til sit eget projekt")
|
||||
}
|
||||
}
|
||||
|
||||
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
|
||||
//
|
||||
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
|
||||
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
|
||||
// godt som det første, og så er der to at trække tilbage i stedet for et.
|
||||
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invitation: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
pending, _ := body["pending"].([]any)
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
|
||||
}
|
||||
first := pending[0].(map[string]any)
|
||||
if first["email"] != "fremmed@example.dk" {
|
||||
t.Errorf("email = %v", first["email"])
|
||||
}
|
||||
|
||||
// Et andet link til den samme indbakke hjælper ingen.
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "DELETE",
|
||||
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if p, _ := after["pending"].([]any); len(p) != 0 {
|
||||
t.Errorf("invitationen står der stadig: %v", after["pending"])
|
||||
}
|
||||
}
|
||||
|
||||
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
|
||||
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
|
||||
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// hasPerson siger, om en liste af personer indeholder et bestemt id.
|
||||
func hasPerson(raw any, id string) bool {
|
||||
list, _ := raw.([]any)
|
||||
for _, item := range list {
|
||||
if p, ok := item.(map[string]any); ok && p["id"] == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -506,8 +509,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateProject)
|
||||
r.Delete("/", s.handleDeleteProject)
|
||||
r.Post("/invites", s.handleInvite)
|
||||
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -354,6 +354,89 @@ func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- invitationer til ét projekt --------------------------------------------------
|
||||
|
||||
// ProjectInvite is somebody invited to a project who has not arrived yet.
|
||||
//
|
||||
// The same shape as NoteInvite and deliberately not the same type: the two rows
|
||||
// differ in which column carries the target, and one struct covering both would
|
||||
// need a field that is empty half the time — which is how a caller ends up
|
||||
// reading the wrong one.
|
||||
type ProjectInvite struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role Role `json:"role"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// ListProjectInvites is who has been invited to a project and not yet taken it up.
|
||||
//
|
||||
// The panel needs them beside the members for the same reason the note panel does:
|
||||
// an invitation sent yesterday is otherwise invisible, so it gets sent again — a
|
||||
// second link to the same inbox, and no way to take either back. That mattered
|
||||
// less while inviting meant typing an address; it matters more now that the panel
|
||||
// offers a list to pick from, because the people who are *missing* from that list
|
||||
// are exactly the ones already invited.
|
||||
//
|
||||
// Expired ones are left out, the same rule the administrator's list follows.
|
||||
func (db *DB) ListProjectInvites(ctx context.Context, projectID string) ([]ProjectInvite, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, email, role, created_at, expires_at
|
||||
FROM invites
|
||||
WHERE project_id = ? AND note_id IS NULL AND accepted_at IS NULL AND expires_at > ?
|
||||
ORDER BY created_at`, projectID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ProjectInvite{}
|
||||
for rows.Next() {
|
||||
var i ProjectInvite
|
||||
var created, expires int64
|
||||
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
out = append(out, i)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteProjectInvite withdraws an invitation to a project, and refuses to
|
||||
// withdraw anything else.
|
||||
//
|
||||
// The project id is in the WHERE rather than checked beforehand, for the same
|
||||
// reason DeleteNoteInvite puts the note id there: the caller's ownership has been
|
||||
// established for *this* project, and a statement that can only touch this
|
||||
// project's rows cannot be talked into deleting somebody else's invite by id.
|
||||
func (db *DB) DeleteProjectInvite(ctx context.Context, projectID, inviteID string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM invites WHERE id = ? AND project_id = ? AND note_id IS NULL`,
|
||||
inviteID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PendingProjectInvite reports whether this address already has a live invitation
|
||||
// to this project, so a second attempt can say so instead of sending another link.
|
||||
func (db *DB) PendingProjectInvite(ctx context.Context, projectID, email string) (bool, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT count(*) FROM invites
|
||||
WHERE project_id = ? AND note_id IS NULL AND lower(email) = lower(?)
|
||||
AND accepted_at IS NULL AND expires_at > ?`,
|
||||
projectID, email, time.Now().Unix()).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// PendingNoteInvite reports whether this address already has a live invitation to
|
||||
// this note, so a second attempt can say so instead of sending a second link.
|
||||
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
|
||||
|
||||
+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>
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SETTINGS, findSettings } from '../src/lib/settingsindex.js';
|
||||
|
||||
/**
|
||||
* Indekset over indstillingerne skal passe på siderne.
|
||||
*
|
||||
* Et indeks ved siden af det, det beskriver, er en kopi, og en kopi driver. Den
|
||||
* dag et afsnit flyttes til en anden fane eller får et nyt anker, holder
|
||||
* søgningen op med at kunne finde det — og det fejler ikke: man får bare ingen
|
||||
* træffere, hvilket ligner en søgning, der virker, på noget der ikke findes.
|
||||
*
|
||||
* Derfor læses filerne her frem for at blive klikket igennem. Halvdelen af
|
||||
* afsnittene vises kun under en betingelse — en kalenderkonto, en afventende
|
||||
* invitation, en administrator — så en prøve, der leder efter dem i en browser,
|
||||
* ville melde fejl på det, der med rette var skjult.
|
||||
*/
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const routes = join(here, '..', 'src', 'routes');
|
||||
const locales = join(here, '..', 'src', 'lib', 'locales');
|
||||
|
||||
/** `/indstillinger/data` → filen, den side tegnes af. */
|
||||
function pageFile(path) {
|
||||
return join(routes, path, '+page.svelte');
|
||||
}
|
||||
|
||||
test('hvert afsnit i indekset findes på den side, det peger på', () => {
|
||||
const missing = [];
|
||||
for (const entry of SETTINGS) {
|
||||
const src = readFileSync(pageFile(entry.path), 'utf8');
|
||||
const hits = src.split(`id="${entry.anchor}"`).length - 1;
|
||||
if (hits !== 1) missing.push(`${entry.path}#${entry.anchor}: ${hits} steder, ventede 1`);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('to afsnit deler ikke et anker', () => {
|
||||
// Ankeret slås op med getElementById, som vælger vilkårligt mellem to ens. Det
|
||||
// er kun et problem inden for den samme side, så nøglen er sti plus anker.
|
||||
const seen = new Set();
|
||||
const dupes = [];
|
||||
for (const entry of SETTINGS) {
|
||||
const key = `${entry.path}#${entry.anchor}`;
|
||||
if (seen.has(key)) dupes.push(key);
|
||||
seen.add(key);
|
||||
}
|
||||
expect(dupes).toEqual([]);
|
||||
});
|
||||
|
||||
test('overskrifterne, indekset peger på, findes på begge sprog', () => {
|
||||
const missing = [];
|
||||
for (const locale of ['da', 'en']) {
|
||||
const src = readFileSync(join(locales, `${locale}.js`), 'utf8');
|
||||
for (const entry of SETTINGS) {
|
||||
for (const key of [entry.key, entry.tab]) {
|
||||
if (!src.includes(`'${key}':`)) missing.push(`${locale}: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('søgningen finder det, man ville skrive', () => {
|
||||
// t() er her den, der ikke oversætter: prøven skal måle indekset og ikke
|
||||
// sprogfilen, og de tekniske ord er dem, der bærer en søgning uanset sprog.
|
||||
const t = (key) => key;
|
||||
const found = (q, admin = true) =>
|
||||
findSettings(q, { t, isAdmin: admin }).map((hit) => `${hit.path}#${hit.anchor}`);
|
||||
|
||||
expect(found('beacon')).toContain('/indstillinger/data#beacon');
|
||||
expect(found('webhook')).toContain('/indstillinger/integrationer#krog');
|
||||
expect(found('imap')).toContain('/indstillinger/integrationer#postkasser');
|
||||
expect(found('2fa')).toContain('/indstillinger#to-faktor');
|
||||
expect(found('ical')).toContain('/indstillinger/integrationer#kalenderfeed');
|
||||
|
||||
// Tomt felt giver ingenting frem for alt: en liste over seksogtredive afsnit
|
||||
// er ikke et svar på et spørgsmål, nogen endnu ikke har stillet.
|
||||
expect(found('')).toEqual([]);
|
||||
expect(found(' ')).toEqual([]);
|
||||
|
||||
// Det, der kun er en administrators, må ikke kunne findes af andre. Siden
|
||||
// afviser dem, og en træffer, der fører til en 403, er et løfte, fladen ikke
|
||||
// kan holde.
|
||||
expect(found('backup', false)).toEqual([]);
|
||||
expect(found('backup', true)).toContain('/indstillinger/data#sikkerhedskopier');
|
||||
});
|
||||
+996
-14
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -338,7 +338,11 @@ export const api = {
|
||||
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
||||
|
||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
||||
// Én vej ind, to måder at sige hvem: et id, man har peget på, eller en adresse,
|
||||
// man har skrevet. Serveren slår adressen op og ender det samme sted, hvis den
|
||||
// hører til en konto her.
|
||||
invite: (projectId, who, role) => post(`/projects/${projectId}/invites`, { ...who, role }),
|
||||
withdrawInvite: (projectId, inviteId) => del(`/projects/${projectId}/invites/${inviteId}`),
|
||||
setMemberRole: (projectId, userId, role) =>
|
||||
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
||||
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { humanMessage } from '$lib/api.js';
|
||||
import { findSettings } from '$lib/settingsindex.js';
|
||||
|
||||
let { open = $bindable(false) } = $props();
|
||||
|
||||
@@ -68,7 +69,20 @@
|
||||
}, 140);
|
||||
});
|
||||
|
||||
/**
|
||||
* Indstillingerne, og hvorfor de ligger øverst.
|
||||
*
|
||||
* De kommer fra et indeks i browseren og ikke fra serveren, så de står der,
|
||||
* mens søgningen efter noter og opgaver stadig er undervejs. Øverst, fordi de
|
||||
* er et svar på "hvor er den indstilling" — et spørgsmål med ét rigtigt svar,
|
||||
* modsat en søgning i noget, man selv har skrevet.
|
||||
*/
|
||||
let settings = $derived(
|
||||
findSettings(query, { t, isAdmin: app.user?.is_admin ?? false, limit: 5 })
|
||||
);
|
||||
|
||||
let results = $derived([
|
||||
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
||||
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||
...tasks.map((t) => ({
|
||||
@@ -108,7 +122,16 @@
|
||||
|
||||
function choose(item) {
|
||||
open = false;
|
||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||
// 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);
|
||||
// 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)',
|
||||
@@ -210,6 +215,7 @@ export const da = {
|
||||
'palette.project': 'Projekt',
|
||||
'palette.task': 'Opgave',
|
||||
'palette.note': 'Note',
|
||||
'palette.setting': 'Indstilling',
|
||||
'nav.today': 'I dag',
|
||||
'nav.upcoming': 'Kommende',
|
||||
'nav.delegated': 'Venter på andre',
|
||||
@@ -220,7 +226,7 @@ export const da = {
|
||||
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
||||
'nav.main': 'Hovedmenu',
|
||||
'nav.search': 'Søg',
|
||||
'nav.searchLong': 'Søg i opgaver og projekter',
|
||||
'nav.searchLong': 'Søg i opgaver, noter, projekter og indstillinger',
|
||||
'nav.noResults': 'Ingen resultater.',
|
||||
'nav.showSidebar': 'Vis sidebjælken',
|
||||
'nav.hideSidebar': 'Skjul sidebjælken',
|
||||
@@ -231,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 ------------------------------------------------
|
||||
@@ -301,6 +310,8 @@ export const da = {
|
||||
|
||||
// --- settings: the frame -------------------------------------------------------
|
||||
'settings.title': 'Indstillinger',
|
||||
'settings.search': 'Søg i indstillinger',
|
||||
'settings.searchNone': 'Ingen indstilling hedder det.',
|
||||
'settings.tab.account': 'Konto',
|
||||
'settings.tab.notifications': 'Notifikationer',
|
||||
'settings.tab.integrations': 'Integrationer',
|
||||
@@ -905,6 +916,14 @@ export const da = {
|
||||
'project.canEdit': 'Kan redigere',
|
||||
'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:',
|
||||
'project.invited': 'Inviteret, ikke kommet endnu',
|
||||
'project.withdrawInvite': 'Træk invitationen til {email} tilbage',
|
||||
'project.inviteByEmail': 'Inviter via e-mail',
|
||||
'project.emailAddress': 'e-mailadresse',
|
||||
'project.role': 'Rolle',
|
||||
|
||||
@@ -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)',
|
||||
@@ -207,6 +212,7 @@ export const en = {
|
||||
'palette.project': 'Project',
|
||||
'palette.task': 'Task',
|
||||
'palette.note': 'Note',
|
||||
'palette.setting': 'Setting',
|
||||
'nav.today': 'Today',
|
||||
'nav.upcoming': 'Upcoming',
|
||||
'nav.delegated': 'Waiting on others',
|
||||
@@ -217,7 +223,7 @@ export const en = {
|
||||
'nav.signOutConfirm': 'Click again to sign out',
|
||||
'nav.main': 'Main menu',
|
||||
'nav.search': 'Search',
|
||||
'nav.searchLong': 'Search tasks and projects',
|
||||
'nav.searchLong': 'Search tasks, notes, projects and settings',
|
||||
'nav.noResults': 'Nothing found.',
|
||||
'nav.showSidebar': 'Show the sidebar',
|
||||
'nav.hideSidebar': 'Hide the sidebar',
|
||||
@@ -228,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 ------------------------------------------------
|
||||
@@ -298,6 +307,8 @@ export const en = {
|
||||
|
||||
// --- settings: the frame -------------------------------------------------------
|
||||
'settings.title': 'Settings',
|
||||
'settings.search': 'Search settings',
|
||||
'settings.searchNone': 'No setting goes by that name.',
|
||||
'settings.tab.account': 'Account',
|
||||
'settings.tab.notifications': 'Notifications',
|
||||
'settings.tab.integrations': 'Integrations',
|
||||
@@ -899,6 +910,14 @@ export const en = {
|
||||
'project.canEdit': 'Can edit',
|
||||
'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:',
|
||||
'project.invited': 'Invited, not arrived yet',
|
||||
'project.withdrawInvite': 'Withdraw the invitation to {email}',
|
||||
'project.inviteByEmail': 'Invite by email',
|
||||
'project.emailAddress': 'email address',
|
||||
'project.role': 'Role',
|
||||
|
||||
+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.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Hvor hver indstilling står, så den kan findes ved at søge efter den.
|
||||
*
|
||||
* Indstillingerne er ti sider med en snes afsnit i alt, og der var ingen vej til
|
||||
* et bestemt af dem andet end at huske, hvilken fane det lå under. ⌘K kunne
|
||||
* opgaver, projekter og noter — altså alt det, man selv har skrevet, og intet af
|
||||
* det, programmet består af.
|
||||
*
|
||||
* Ét indeks og ikke to søgninger. Feltet øverst i indstillingerne og paletten
|
||||
* stiller det samme spørgsmål, og to lister over de samme afsnit ville være to
|
||||
* steder at glemme et nyt et.
|
||||
*
|
||||
* Overskrifterne står ikke her. Hver post peger på den nøgle, afsnittets egen
|
||||
* overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af sig
|
||||
* selv — og søgningen kan begge sprog uden at kende nogen af dem.
|
||||
*
|
||||
* `words` er derimod skrevet her, og det er med vilje: det er søgeord, ingen får
|
||||
* at se. De er de tekniske navne — `imap`, `webcal`, `2fa`, `webhook` — som er de
|
||||
* samme på dansk og engelsk, og som er dem, man taster, når man leder efter det,
|
||||
* man kender andetsteds fra. Det, der skal oversættes, er overskriften, og den
|
||||
* ligger i sprogfilen.
|
||||
*/
|
||||
|
||||
/** Hver post: siden, ankeret på den, overskriftens nøgle, fanens nøgle. */
|
||||
export const SETTINGS = [
|
||||
// Konto
|
||||
{ path: '/indstillinger', anchor: 'profil', key: 'account.profile', tab: 'settings.tab.account', words: 'profil profile navn name email mail tidszone timezone sprog language' },
|
||||
{ path: '/indstillinger', anchor: 'udseende', key: 'account.appearance', tab: 'settings.tab.account', words: 'tema theme dark light farve color' },
|
||||
{ path: '/indstillinger', anchor: 'look', key: 'account.look', tab: 'settings.tab.account', words: 'serif terminal' },
|
||||
{ path: '/indstillinger', anchor: 'stoerrelser', key: 'account.sizes', tab: 'settings.tab.account', words: 'skrift font zoom' },
|
||||
{ path: '/indstillinger', anchor: 'links', key: 'link.title', tab: 'settings.tab.account', words: 'browser safari chrome firefox' },
|
||||
{ path: '/indstillinger', anchor: 'adgangskode', key: 'account.password', tab: 'settings.tab.account', words: 'password kodeord' },
|
||||
{ path: '/indstillinger', anchor: 'to-faktor', key: 'account.totp', tab: 'settings.tab.account', words: '2fa totp authenticator to-faktor' },
|
||||
{ path: '/indstillinger', anchor: 'passkey', key: 'passkey.title', tab: 'settings.tab.account', words: 'passkey webauthn touch id face' },
|
||||
{ path: '/indstillinger', anchor: 'enheder', key: 'account.devices', tab: 'settings.tab.account', words: 'session sessioner devices log ud' },
|
||||
{ path: '/indstillinger', anchor: 'stoette', key: 'account.support', tab: 'settings.tab.account', words: 'donate kaffe coffee' },
|
||||
|
||||
// Notifikationer
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
|
||||
|
||||
// Integrationer
|
||||
{ path: '/indstillinger/integrationer', anchor: 'gmail', key: 'int.gmail', tab: 'settings.tab.integrations', words: 'gmail google oauth' },
|
||||
{ path: '/indstillinger/integrationer', anchor: 'kalender', key: 'int.calendar', tab: 'settings.tab.integrations', words: 'google calendar' },
|
||||
{ path: '/indstillinger/integrationer', anchor: 'abonnementer', key: 'int.subscriptions', tab: 'settings.tab.integrations', words: 'ics webcal abonnement subscribe' },
|
||||
{ path: '/indstillinger/integrationer', anchor: 'kalenderfeed', key: 'int.feed', tab: 'settings.tab.integrations', words: 'ical ics feed apple thunderbird' },
|
||||
{ path: '/indstillinger/integrationer', anchor: 'mail-til-opgave', key: 'int.mailToTask', tab: 'settings.tab.integrations', words: 'mail email forward videresend' },
|
||||
{ path: '/indstillinger/integrationer', anchor: 'krog', key: 'int.hookToTask', tab: 'settings.tab.integrations', words: 'webhook hook curl shortcut genvej' },
|
||||
{ path: '/indstillinger/integrationer', anchor: 'caldav-server', key: 'int.caldav', tab: 'settings.tab.integrations', words: 'caldav reminders' },
|
||||
{ path: '/indstillinger/integrationer', anchor: 'postkasser', key: 'int.mailboxes', tab: 'settings.tab.integrations', words: 'imap icloud fastmail postkasse' },
|
||||
|
||||
// AI
|
||||
{ path: '/indstillinger/ai', anchor: 'ai', key: 'ai.title', tab: 'settings.tab.ai', words: 'ai model anthropic openai claude gpt gemini api key' },
|
||||
{ path: '/indstillinger/ai', anchor: 'ugentlig', key: 'ai.weekly', tab: 'settings.tab.ai', words: 'uge weekly opsamling' },
|
||||
|
||||
// Tokens
|
||||
{ path: '/indstillinger/tokens', anchor: 'tokens', key: 'tokens.title', tab: 'settings.tab.tokens', words: 'token api mcp connector bearer' },
|
||||
|
||||
// Brugere, historik, fejl — kun for administratorer
|
||||
{ path: '/indstillinger/brugere', anchor: 'invitation', key: 'users.invite', tab: 'settings.tab.users', words: 'invite invitation bruger user', admin: true },
|
||||
{ path: '/indstillinger/brugere', anchor: 'afventende', key: 'users.pending', tab: 'settings.tab.users', words: 'pending afventer', admin: true },
|
||||
{ path: '/indstillinger/brugere', anchor: 'konti', key: 'users.accounts', tab: 'settings.tab.users', words: 'konti accounts admin', admin: true },
|
||||
{ path: '/indstillinger/historik', anchor: 'historik', key: 'history.title', tab: 'settings.tab.history', words: 'aktivitet activity log', admin: true },
|
||||
{ path: '/indstillinger/fejl', anchor: 'fejl', key: 'errors.title', tab: 'settings.tab.errors', words: 'error 500 log', admin: true },
|
||||
|
||||
// Data
|
||||
{ path: '/indstillinger/data', anchor: 'import', key: 'data.import', tab: 'settings.tab.data', words: 'import todoist csv json' },
|
||||
{ path: '/indstillinger/data', anchor: 'faerdige', key: 'done.title', tab: 'settings.tab.data', words: 'done completed oprydning' },
|
||||
{ path: '/indstillinger/data', anchor: 'papirkurv', key: 'data.trash', tab: 'settings.tab.data', words: 'trash slettet deleted' },
|
||||
{ path: '/indstillinger/data', anchor: 'sikkerhedskopier', key: 'data.backups', tab: 'settings.tab.data', words: 'backup sikkerhedskopi', admin: true },
|
||||
{ path: '/indstillinger/data', anchor: 'eksport', key: 'data.export', tab: 'settings.tab.data', words: 'export eksport zip markdown' },
|
||||
{ path: '/indstillinger/data', anchor: 'skabeloner', key: 'data.templates', tab: 'settings.tab.data', words: 'template skabelon' },
|
||||
{ path: '/indstillinger/data', anchor: 'beacon', key: 'beacon.title', tab: 'settings.tab.data', words: 'beacon tæller collector telemetri' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Samme foldning som serverens `fold`-kolonne.
|
||||
*
|
||||
* `remove_diacritics` i SQLite gør intet ved ø, æ og å — de er egne bogstaver og
|
||||
* ikke accenter — og en dansk søgning, der ikke kan finde "sikkerhedskopier" fra
|
||||
* "sikkerhedskopier" skrevet på et fremmed tastatur, er brudt for sine egne
|
||||
* brugere. Derfor samme oversættelse her som der, og `normalize` ovenpå til alt
|
||||
* det, Unicode faktisk regner for accenter.
|
||||
*/
|
||||
export function fold(text) {
|
||||
return (text ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/ø/g, 'o')
|
||||
.replace(/æ/g, 'ae')
|
||||
.replace(/å/g, 'aa')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Indstillingerne, der svarer til det skrevne.
|
||||
*
|
||||
* Alle ordene skal findes, hvert af dem som begyndelsen af et ord — "sik kop"
|
||||
* finder sikkerhedskopierne. Den, der søger, skriver forfra; en søgning, der også
|
||||
* matcher midt i et ord, finder "opgave" i "hovedopgaven" og larmer.
|
||||
*
|
||||
* `t` gives med udefra frem for at blive importeret. Det holder filen fri af
|
||||
* Sveltes runes, så den kan læses af en almindelig prøve uden en komponent
|
||||
* omkring sig.
|
||||
*/
|
||||
export function findSettings(query, { t, isAdmin = false, limit = 8 } = {}) {
|
||||
const terms = fold(query).split(/\s+/).filter(Boolean);
|
||||
if (!terms.length) return [];
|
||||
|
||||
const hits = [];
|
||||
for (const entry of SETTINGS) {
|
||||
if (entry.admin && !isAdmin) continue;
|
||||
|
||||
const title = t(entry.key);
|
||||
const tab = t(entry.tab);
|
||||
const words = fold(`${title} ${tab} ${entry.words}`)
|
||||
.split(/[\s/·,-]+/)
|
||||
.filter(Boolean);
|
||||
|
||||
// Alle ordene skal findes, og hvert af dem forfra i et ord: "sikker" finder
|
||||
// sikkerhedskopierne, "kopier" gør ikke. Den, der søger, skriver
|
||||
// begyndelsen af det, den leder efter — en søgning, der også rammer midt i
|
||||
// et ord, finder "opgave" inde i "hovedopgaven" og larmer.
|
||||
if (!terms.every((term) => words.some((word) => word.startsWith(term)))) continue;
|
||||
|
||||
// Det, der hedder det, man søgte efter, før det, der blot nævner det. Uden
|
||||
// den her ligger rækkefølgen fast som filens, og "beacon" ville få hele
|
||||
// Data-siden ned over sig, fordi den står først.
|
||||
const folded = fold(title);
|
||||
const rank = folded.startsWith(terms[0]) ? 0 : folded.includes(terms[0]) ? 1 : 2;
|
||||
hits.push({ rank, entry: { ...entry, title, tab, href: `${entry.path}#${entry.anchor}` } });
|
||||
}
|
||||
|
||||
return hits
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.slice(0, limit)
|
||||
.map((hit) => hit.entry);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
* is six places for them to drift apart.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { findSettings } from '$lib/settingsindex.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -37,6 +39,69 @@
|
||||
]);
|
||||
|
||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
||||
|
||||
/**
|
||||
* Søgningen efter en indstilling.
|
||||
*
|
||||
* Ti faner med en snes afsnit i alt, og den eneste vej til et bestemt af dem
|
||||
* var at huske, hvilken fane det lå under. Feltet her og ⌘K spørger det samme
|
||||
* indeks — se settingsindex.js — så et nyt afsnit kun skal skrives ét sted for
|
||||
* at kunne findes to.
|
||||
*/
|
||||
let query = $state('');
|
||||
let hits = $derived(findSettings(query, { t, isAdmin: app.user?.is_admin ?? false }));
|
||||
|
||||
function go(hit) {
|
||||
query = '';
|
||||
goto(hit.href);
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
query = '';
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && hits.length) {
|
||||
event.preventDefault();
|
||||
go(hits[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruller hen til afsnittet, når adressen nævner det, og blinker det.
|
||||
*
|
||||
* Browserens egen springen til et anker kan ikke bruges her: siden hentes af
|
||||
* ruteren, og afsnittet findes først, når dens data er hjemme — så et spring,
|
||||
* der sker med det samme, lander på en side, der endnu er tom. Derfor prøves
|
||||
* det, indtil elementet er der, og gives op efter to sekunder frem for at
|
||||
* prøve for evigt.
|
||||
*
|
||||
* Og blinket er ikke pynt. Man er landet et sted, man ikke selv rullede hen
|
||||
* til, midt i en side med otte afsnit, der ligner hinanden; uden det skal man
|
||||
* læse sig frem til, hvor man er.
|
||||
*/
|
||||
$effect(() => {
|
||||
const anchor = $page.url.hash.slice(1);
|
||||
if (!anchor) return;
|
||||
|
||||
let tries = 0;
|
||||
let timer;
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const look = () => {
|
||||
const el = document.getElementById(anchor);
|
||||
if (!el) {
|
||||
if (tries++ < 40) timer = setTimeout(look, 50);
|
||||
return;
|
||||
}
|
||||
el.scrollIntoView({ block: 'start', behavior: reduced ? 'auto' : 'smooth' });
|
||||
el.classList.add('landed');
|
||||
timer = setTimeout(() => el.classList.remove('landed'), 1600);
|
||||
};
|
||||
look();
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="settings">
|
||||
@@ -44,6 +109,34 @@
|
||||
<h1>{t('settings.title')}</h1>
|
||||
</header>
|
||||
|
||||
<div class="find">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
{onkeydown}
|
||||
placeholder={t('settings.search')}
|
||||
aria-label={t('settings.search')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if query.trim()}
|
||||
{#if hits.length}
|
||||
<ul class="hits">
|
||||
{#each hits as hit (hit.href)}
|
||||
<li>
|
||||
<button onclick={() => go(hit)}>
|
||||
<span class="where">{hit.tab}</span>
|
||||
<span class="what">{hit.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="nohits">{t('settings.searchNone')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav aria-label={t('settings.title')}>
|
||||
{#each sections as section (section.href)}
|
||||
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
||||
@@ -109,9 +202,108 @@
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.find {
|
||||
position: relative;
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.find input {
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.find input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Over indholdet frem for at skubbe det ned: listen kommer og går for hvert
|
||||
bogstav, og en side, der hopper under det, man læser, er svær at sigte i. */
|
||||
.hits {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: var(--s1) 0 0;
|
||||
padding: var(--s1);
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hits button {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.hits button:hover {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* Fanen står før navnet, fordi svaret på "hvor ligger det?" er halvdelen af
|
||||
det, man søgte efter. */
|
||||
.where {
|
||||
flex: none;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.what {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.nohits {
|
||||
margin: var(--s2) 0 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.settings :global(h3[id]) {
|
||||
scroll-margin-top: var(--s5);
|
||||
}
|
||||
|
||||
/* Blinket, når man er landet et sted, man ikke selv rullede hen til. Kanten og
|
||||
ikke baggrunden: en baggrund, der skifter, gør teksten svær at læse i netop
|
||||
det øjeblik, man er kommet for at læse den. */
|
||||
.settings :global(.landed) {
|
||||
animation: landed 1.6s var(--ease);
|
||||
}
|
||||
|
||||
@keyframes landed {
|
||||
0%,
|
||||
60% {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 2px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings :global(.landed) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- shared form chrome, for the child routes ------------------------------- */
|
||||
|
||||
.settings :global(section.panel) {
|
||||
/* Der er en fast linje øverst på skærmen. Uden den her lander afsnittets
|
||||
overskrift bag den, når man springer hertil fra en søgning. */
|
||||
scroll-margin-top: var(--s5);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -151,7 +343,8 @@
|
||||
|
||||
.settings :global(.field input),
|
||||
.settings :global(.field select),
|
||||
.settings :global(.field textarea) {
|
||||
.settings :global(.field textarea),
|
||||
.settings :global(.field output) {
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s3);
|
||||
background: var(--surface-sunken);
|
||||
@@ -168,6 +361,22 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Adresserne — feed'et, krogen, CalDAV-serveren — vises i et <output> og ikke i
|
||||
et skrivebeskyttet <input>. Et input kan ikke ombryde: det ruller indeni, så
|
||||
på en telefon står halvdelen af adressen uden for kassen, og der er ingen måde
|
||||
at se resten på. <output> er stadig et felt, en <label for> kan pege på, og det
|
||||
er tekst, der ombryder.
|
||||
|
||||
`user-select: all` er der, fordi det var det eneste, inputtet gjorde bedre: ét
|
||||
tryk tog hele adressen. Uden den skulle man trække hen over fire linjer. */
|
||||
.settings :global(.field output) {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The field error sits under its own input rather than in a summary at the top:
|
||||
the server answers with a map of field to message, and putting each one where
|
||||
it belongs is the whole point of that shape. */
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
*/
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="profil">
|
||||
<header>
|
||||
<h2>{t('account.profile')}</h2>
|
||||
<p class="hint">
|
||||
@@ -332,7 +332,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="udseende">
|
||||
<header>
|
||||
<h2>{t('account.appearance')}</h2>
|
||||
<p class="hint">
|
||||
@@ -366,7 +366,7 @@
|
||||
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
||||
they are separate settings because they are separate questions. -->
|
||||
<div class="looks">
|
||||
<h3>{t('account.look')}</h3>
|
||||
<h3 id="look">{t('account.look')}</h3>
|
||||
<p class="hint">{t('account.lookHint')}</p>
|
||||
<div class="look-row">
|
||||
{#each LOOKS as option (option.id)}
|
||||
@@ -390,7 +390,7 @@
|
||||
Separate on purpose — one is a glance down a list of names, the other is a
|
||||
paragraph you sit and read. -->
|
||||
<div class="sizes">
|
||||
<h3>{t('account.sizes')}</h3>
|
||||
<h3 id="stoerrelser">{t('account.sizes')}</h3>
|
||||
<p class="hint">{t('account.sizesHint')}</p>
|
||||
<div class="size-row">
|
||||
<div class="size-field">
|
||||
@@ -421,7 +421,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="links">
|
||||
<header>
|
||||
<h2>{t('link.title')}</h2>
|
||||
<p class="hint">{t('link.hint')}</p>
|
||||
@@ -464,7 +464,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="adgangskode">
|
||||
<header>
|
||||
<h2>{t('account.password')}</h2>
|
||||
<p class="hint">
|
||||
@@ -506,7 +506,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="to-faktor">
|
||||
<header>
|
||||
<h2>{t('account.totp')}</h2>
|
||||
<p class="hint">{t('account.totpHint')}</p>
|
||||
@@ -616,7 +616,7 @@
|
||||
|
||||
<!-- Between two-factor and the device list, because it belongs to both: a passkey
|
||||
is a way in, and it is a thing on a device you might later want to revoke. -->
|
||||
<section class="panel">
|
||||
<section class="panel" id="passkey">
|
||||
<header>
|
||||
<h2>{t('passkey.title')}</h2>
|
||||
<p class="hint">{t('passkey.hint')}</p>
|
||||
@@ -655,7 +655,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="enheder">
|
||||
<header>
|
||||
<h2>{t('account.devices')}</h2>
|
||||
<p class="hint">
|
||||
@@ -687,7 +687,7 @@
|
||||
work. A plain anchor with an inline SVG — no third-party script, no webfont,
|
||||
no tracking — so it works offline and cannot phone home. See the coffee-cup
|
||||
snippet for why not their generated button. -->
|
||||
<section class="panel">
|
||||
<section class="panel" id="stoette">
|
||||
<header>
|
||||
<h2>{t('account.support')}</h2>
|
||||
<p class="hint">{t('account.supportHint')}</p>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="ai">
|
||||
<header>
|
||||
<h2>{t('ai.title')}</h2>
|
||||
<p class="hint">
|
||||
@@ -157,7 +157,7 @@
|
||||
</section>
|
||||
|
||||
{#if settings?.provider && settings?.has_key}
|
||||
<section class="panel">
|
||||
<section class="panel" id="ugentlig">
|
||||
<header>
|
||||
<h2>{t('ai.weekly')}</h2>
|
||||
<p class="hint">{t('ai.weeklyHint')}</p>
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
const expires = shortDate;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="invitation">
|
||||
<header>
|
||||
<h2>{t('users.invite')}</h2>
|
||||
<p class="hint">
|
||||
@@ -163,7 +163,7 @@
|
||||
</section>
|
||||
|
||||
{#if invites.length}
|
||||
<section class="panel">
|
||||
<section class="panel" id="afventende">
|
||||
<header>
|
||||
<h2>{t('users.pending')}</h2>
|
||||
<p class="hint">
|
||||
@@ -191,7 +191,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="konti">
|
||||
<header>
|
||||
<h2>{t('users.accounts')}</h2>
|
||||
<p class="hint">{t('users.accountsHint')}</p>
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="import">
|
||||
<header>
|
||||
<h2>{t('data.import')}</h2>
|
||||
<p class="hint">
|
||||
@@ -279,7 +279,7 @@
|
||||
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
||||
to the four that are read every day, and it belongs with the other "where did
|
||||
that go" questions rather than above them. -->
|
||||
<section class="panel">
|
||||
<section class="panel" id="faerdige">
|
||||
<header>
|
||||
<h2>{t('done.title')}</h2>
|
||||
<p class="hint">{t('data.doneHint')}</p>
|
||||
@@ -289,7 +289,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="papirkurv">
|
||||
<header>
|
||||
<h2>{t('data.trash')}</h2>
|
||||
<p class="hint">
|
||||
@@ -322,7 +322,7 @@
|
||||
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
||||
is a complete copy of the database, so this panel hands out everybody's data. -->
|
||||
{#if app.user?.is_admin}
|
||||
<section class="panel">
|
||||
<section class="panel" id="sikkerhedskopier">
|
||||
<header>
|
||||
<h2>{t('data.backups')}</h2>
|
||||
<p class="hint">
|
||||
@@ -380,7 +380,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="eksport">
|
||||
<header>
|
||||
<h2>{t('data.export')}</h2>
|
||||
<p class="hint">
|
||||
@@ -428,7 +428,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="skabeloner">
|
||||
<header>
|
||||
<h2>{t('data.templates')}</h2>
|
||||
<p class="hint">
|
||||
@@ -512,7 +512,7 @@
|
||||
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
||||
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
||||
dem, ikke gemt væk under en fane, ingen åbner. -->
|
||||
<section class="panel">
|
||||
<section class="panel" id="beacon">
|
||||
<header>
|
||||
<h2>{t('beacon.title')}</h2>
|
||||
<p class="hint">{t('beacon.hint')}</p>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="fejl">
|
||||
<header>
|
||||
<h2>{t('errors.title')}</h2>
|
||||
<p class="hint">
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="historik">
|
||||
<header>
|
||||
<h2>{t('history.title')}</h2>
|
||||
<p class="hint">
|
||||
|
||||
@@ -416,7 +416,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="gmail">
|
||||
<header>
|
||||
<h2>{t('int.gmail')}</h2>
|
||||
<p class="hint">
|
||||
@@ -460,7 +460,7 @@
|
||||
it has to match Google's copy exactly, and the error Google gives
|
||||
when it does not names neither value. Google afviser desuden
|
||||
private IP-adresser over http — det skal være https. -->
|
||||
<input id="redirect" readonly value={client.redirect_uri} />
|
||||
<output id="redirect">{client.redirect_uri}</output>
|
||||
</div>
|
||||
|
||||
<form onsubmit={saveClient}>
|
||||
@@ -520,7 +520,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="kalender">
|
||||
<header>
|
||||
<h2>{t('int.calendar')}</h2>
|
||||
<p class="hint">{t('int.calendarHint')}</p>
|
||||
@@ -546,7 +546,7 @@
|
||||
{#if app.user?.is_admin}
|
||||
<div class="field">
|
||||
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
||||
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
|
||||
<output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
|
||||
<p class="hint">{t('int.calendarSameClient')}</p>
|
||||
</div>
|
||||
{:else if !calendar.has_client}
|
||||
@@ -605,7 +605,7 @@
|
||||
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
||||
{#if calendar !== null}
|
||||
<div class="subs">
|
||||
<h3>{t('int.subscriptions')}</h3>
|
||||
<h3 id="abonnementer">{t('int.subscriptions')}</h3>
|
||||
<p class="hint">{t('int.subscriptionsHint')}</p>
|
||||
|
||||
{#if calendar.subscriptions?.length}
|
||||
@@ -643,7 +643,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="kalenderfeed">
|
||||
<header>
|
||||
<h2>{t('int.feed')}</h2>
|
||||
<p class="hint">
|
||||
@@ -653,7 +653,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="feed">{t('int.address')}</label>
|
||||
<input id="feed" class="mono" value={feedURL} readonly />
|
||||
<output id="feed" class="mono">{feedURL}</output>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -665,7 +665,7 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="mail-til-opgave">
|
||||
<header>
|
||||
<h2>{t('int.mailToTask')}</h2>
|
||||
<p class="hint">
|
||||
@@ -681,7 +681,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="mail">{t('int.yourAddress')}</label>
|
||||
<input id="mail" class="mono" value={mailAddress} readonly />
|
||||
<output id="mail" class="mono">{mailAddress}</output>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -690,7 +690,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="krog">
|
||||
<header>
|
||||
<h2>{t('int.hookToTask')}</h2>
|
||||
<p class="hint">
|
||||
@@ -700,7 +700,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="hook">{t('int.yourHook')}</label>
|
||||
<input id="hook" class="mono" value={hookURL} readonly />
|
||||
<output id="hook" class="mono">{hookURL}</output>
|
||||
<p class="hint">
|
||||
{t('int.hookHow')}
|
||||
</p>
|
||||
@@ -711,8 +711,8 @@
|
||||
kroppen bare er den linje, opgaven skal hedde. -->
|
||||
<div class="field">
|
||||
<label for="hookcurl">{t('int.hookExample')}</label>
|
||||
<textarea id="hookcurl" class="mono" rows="3" readonly
|
||||
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
|
||||
<output id="hookcurl" class="mono"
|
||||
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -722,7 +722,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="caldav-server">
|
||||
<header>
|
||||
<h2>{t('int.caldav')}</h2>
|
||||
<p class="hint">
|
||||
@@ -732,7 +732,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="caldav">{t('int.server')}</label>
|
||||
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
|
||||
<output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
|
||||
<p class="hint">
|
||||
{t('int.caldavAuth')}
|
||||
</p>
|
||||
@@ -743,7 +743,7 @@
|
||||
rather than a second mode inside the Gmail one, because they are not the same
|
||||
thing wearing different clothes — one is an app you sign in through, the other
|
||||
is a password you hold. -->
|
||||
<section class="panel">
|
||||
<section class="panel" id="postkasser">
|
||||
<header>
|
||||
<h2>{t('int.mailboxes')}</h2>
|
||||
<p class="hint">{t('int.mailboxesHint')}</p>
|
||||
@@ -817,7 +817,7 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.setup input[readonly] {
|
||||
.setup output {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
@@ -884,4 +884,38 @@
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* The bullet list this was is why the page scrolled sideways on a phone. A
|
||||
subscription address is one word with nothing to break at, so it cannot wrap
|
||||
on its own — and the widest element on a page sets the width of the page, not
|
||||
of its own line. Hence `anywhere`, which breaks mid-word when there is no
|
||||
other opportunity, plus `min-width: 0` so the flex item is allowed to be
|
||||
narrower than its content in the first place. */
|
||||
.subs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.subs-list li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subs-list .url {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.subs-list button {
|
||||
margin-top: var(--s1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="dagens-plan">
|
||||
<header>
|
||||
<h2>{t('ai.plan')}</h2>
|
||||
<p class="hint">{t('ai.planHint')}</p>
|
||||
@@ -252,7 +252,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="push">
|
||||
<header>
|
||||
<h2>{t('push.title')}</h2>
|
||||
<p class="hint">
|
||||
@@ -296,7 +296,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="seneste">
|
||||
<header>
|
||||
<h2>{t('push.recent')}</h2>
|
||||
{#if unread > 0}
|
||||
@@ -335,7 +335,7 @@
|
||||
</section>
|
||||
|
||||
{#if version}
|
||||
<section class="panel">
|
||||
<section class="panel" id="version">
|
||||
<header>
|
||||
<h2>{t('push.version')}</h2>
|
||||
</header>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
const expired = (iso) => iso && new Date(iso) < new Date();
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="tokens">
|
||||
<header>
|
||||
<h2>{t('tokens.title')}</h2>
|
||||
<p class="hint">
|
||||
|
||||
@@ -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));
|
||||
@@ -1279,7 +1363,7 @@
|
||||
lovet i en besked, der ikke kom af sted. -->
|
||||
<p class="invitelink">
|
||||
{t('notes.inviteNoMail')}
|
||||
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
|
||||
<output>{shareInviteLink}</output>
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -2216,11 +2300,23 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.invitelink input {
|
||||
/* <output> og ikke et skrivebeskyttet <input>: et input kan ikke ombryde, så et
|
||||
invitationslink stod halvt uden for kassen på en telefon, og resten kunne kun
|
||||
nås ved at rulle inde i feltet. `user-select: all` beholder det, inputtet var
|
||||
god til — ét tryk tager hele linket. */
|
||||
.invitelink output {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--s1);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.addshare {
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
let inviteRole = $state('editor');
|
||||
let inviteLink = $state('');
|
||||
let inviteError = $state('');
|
||||
// De to andre halvdele af delingspanelet: hvem der kan tilføjes, og hvem der er
|
||||
// inviteret og ikke kommet endnu. Begge kommer fra det samme svar som
|
||||
// medlemmerne, så panelet åbner færdigt i ét kald.
|
||||
let candidates = $state([]);
|
||||
let pending = $state([]);
|
||||
let sharePick = $state('');
|
||||
let pickRole = $state('editor');
|
||||
|
||||
let id = $derived($page.params.id);
|
||||
|
||||
@@ -101,7 +108,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (showShare && project) {
|
||||
api.listMembers(project.id).then((r) => (members = r.members));
|
||||
api.listMembers(project.id).then(readMembers);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -512,23 +519,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function invite(event) {
|
||||
event.preventDefault();
|
||||
// Ét sted at læse svaret, fordi der nu er tre lister i det og fire steder, der
|
||||
// henter det. Tre tildelinger skrevet fire gange er tre, der kan blive glemt.
|
||||
function readMembers(r) {
|
||||
members = r.members;
|
||||
candidates = r.candidates ?? [];
|
||||
pending = r.pending ?? [];
|
||||
}
|
||||
|
||||
async function share(who, role) {
|
||||
inviteError = '';
|
||||
inviteLink = '';
|
||||
try {
|
||||
const result = await api.invite(project.id, inviteEmail, inviteRole);
|
||||
inviteEmail = '';
|
||||
const result = await api.invite(project.id, who, role);
|
||||
if (result.link && !result.emailed) {
|
||||
// No mail server, or delivery failed. The link is the only way this
|
||||
// invite reaches anybody, so it is shown rather than swallowed.
|
||||
inviteLink = result.link;
|
||||
}
|
||||
members = (await api.listMembers(project.id)).members;
|
||||
readMembers(await api.listMembers(project.id));
|
||||
} catch (e) {
|
||||
inviteError = humanMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function invite(event) {
|
||||
event.preventDefault();
|
||||
const email = inviteEmail.trim();
|
||||
if (!email) return;
|
||||
await share({ email }, inviteRole);
|
||||
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);
|
||||
readMembers(await api.listMembers(project.id));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
||||
@@ -765,7 +810,7 @@
|
||||
class="remove"
|
||||
onclick={async () => {
|
||||
await api.removeMember(project.id, member.user_id);
|
||||
members = (await api.listMembers(project.id)).members;
|
||||
readMembers(await api.listMembers(project.id));
|
||||
}}
|
||||
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
||||
>
|
||||
@@ -774,7 +819,66 @@
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- De inviterede står ved siden af medlemmerne. Uden dem er en invitation
|
||||
sendt i går usynlig: personen har ingen konto, så de er hverken
|
||||
medlem eller nogen, man kan vælge — og så bliver den sendt igen. -->
|
||||
{#if pending.length}
|
||||
<ul class="members invited">
|
||||
{#each pending as invite (invite.id)}
|
||||
<li>
|
||||
<span class="avatar waiting">@</span>
|
||||
<span class="member-name">{invite.email}</span>
|
||||
<span class="role">{t('project.invited')}</span>
|
||||
{#if isOwner}
|
||||
<button
|
||||
class="remove"
|
||||
onclick={() => withdraw(invite)}
|
||||
aria-label={t('project.withdrawInvite', { email: invite.email })}>×</button
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</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å
|
||||
en kollega, man kender navnet på — og konti her bliver kun til ved
|
||||
invitation, så listen er en adressebog og ikke en udlevering. -->
|
||||
{#if candidates.length}
|
||||
<div class="addshare">
|
||||
<select bind:value={sharePick} aria-label={t('project.pickPerson')}>
|
||||
<option value="">{t('project.pickPerson')}</option>
|
||||
{#each candidates as person (person.id)}
|
||||
<option value={person.id}>{person.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={pickRole} aria-label={t('project.roleForPicked')}>
|
||||
<option value="editor">{t('project.canEdit')}</option>
|
||||
<option value="viewer">{t('project.canView')}</option>
|
||||
</select>
|
||||
<button
|
||||
disabled={!sharePick}
|
||||
onclick={async () => {
|
||||
const id = sharePick;
|
||||
sharePick = '';
|
||||
await share({ user_id: id }, pickRole);
|
||||
}}>{t('project.shareWith')}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Og den, der ikke er her endnu. Samme felt til begge udfald: har
|
||||
adressen en konto, bliver det en deling med det samme, og ellers en
|
||||
invitation. Hvem der har en konto, skal man ikke vide for at dele. -->
|
||||
<p class="byemail-hint">{t('project.orByEmail')}</p>
|
||||
<form onsubmit={invite}>
|
||||
<input
|
||||
bind:value={inviteEmail}
|
||||
@@ -1396,6 +1500,45 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* De inviterede står under medlemmerne med en tynd streg imellem: de er på vej
|
||||
ind, ikke inde, og en liste uden skel læses som om de allerede var det. */
|
||||
.invited {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: var(--s2);
|
||||
}
|
||||
|
||||
/* Ingen farve at give dem: en invitation har en adresse og ellers ingenting,
|
||||
og en tilfældig farve ville love et menneske, der ikke findes endnu. */
|
||||
.avatar.waiting {
|
||||
background: var(--surface-sunken);
|
||||
border: 1px dashed var(--line);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.addshare {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
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);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
Reference in New Issue
Block a user