Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 861820b785 docs: byggeprompten, så den kan hentes uden en terminal
Filen har ligget utracket i arbejdstræet hele tiden. Den ligger her på en
gren og ikke på main, fordi spørgsmålet om den hører til i repoet stadig er
Kristians — det her er kun en vej til at hente den fra en browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:14:52 +02:00
28 changed files with 1621 additions and 1389 deletions
+1537
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -96,10 +96,9 @@ 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` | 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}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `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. |
+2 -9
View File
@@ -26,10 +26,6 @@ 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,
@@ -40,11 +36,8 @@ 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. 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.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **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.
+4 -64
View File
@@ -2307,15 +2307,7 @@ paths:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
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.
summary: Who the project is shared with.
responses:
"200":
description: OK
@@ -2327,17 +2319,6 @@ 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}:
@@ -2403,32 +2384,13 @@ paths:
application/json:
schema:
type: object
required: [email, role]
properties:
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.
email: { type: string, format: email }
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: Invited. No account yet, so a link was made.
description: Created
content:
application/json:
schema:
@@ -2437,30 +2399,8 @@ 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 } }
-22
View File
@@ -8,28 +8,6 @@ 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.
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
+14 -131
View File
@@ -452,100 +452,22 @@ 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) {
projectID := chi.URLParam(r, "projectID")
members, err := s.db.ListMembers(r.Context(), projectID)
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "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
}
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)
writeJSON(w, http.StatusOK, map[string]any{"members": out})
}
type inviteRequest struct {
// 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"`
Email string `json:"email"`
Role string `json:"role"`
}
type inviteResponse struct {
@@ -556,14 +478,8 @@ type inviteResponse struct {
Emailed bool `json:"emailed"`
}
// 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.
// 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.
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
var req inviteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -580,13 +496,8 @@ 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 req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
if req.UserID == "" && !strings.Contains(email, "@") {
if email == "" || !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
@@ -597,47 +508,19 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
// 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 {
// 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 {
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
return
}
// 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 {
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
s.internal(w, r, "add member", err)
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
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")
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
writeJSON(w, http.StatusOK, map[string]any{"added": true})
return
}
-141
View File
@@ -486,144 +486,3 @@ 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
}
-1
View File
@@ -506,7 +506,6 @@ 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)
})
-83
View File
@@ -354,89 +354,6 @@ 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) {
-89
View File
@@ -1,89 +0,0 @@
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');
});
+1 -240
View File
@@ -1104,9 +1104,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
// `exact`, fordi siden nu også har "Rolle for den valgte" ved siden af
// vælgeren — og getByLabel matcher som understreng.
await page.getByLabel('Rolle', { exact: true }).selectOption('viewer');
await page.getByLabel('Rolle').selectOption('viewer');
await page.getByRole('button', { name: 'Inviter' }).click();
const link = await page.locator('.link-out code').textContent();
@@ -4362,240 +4360,3 @@ test('dagens plan står på I dag, og ikke kun som en besked', async ({ page })
expect(trouble).toEqual([]);
});
/**
* Adresserne på integrationssiden kan ses helt på en telefon.
*
* En URL er ét ord på et par hundrede tegn uden et mellemrum i, og den kan derfor
* ikke ombryde af sig selv. Det gav to forskellige fejl på den samme side, og
* prøven her holder øje med dem begge:
*
* Abonnementets adresse lå i en liste uden noget, der lod den bryde, og det
* bredeste element på en side bestemmer sidens bredde — så hele indholdsspalten fik
* et vandret rullepanel, ikke bare den ene linje.
*
* Feed'et, krogen og CalDAV-serveren lå i skrivebeskyttede <input>, som ruller
* indeni i stedet for at ombryde. Der var ikke noget rullepanel at se: adressen
* stoppede bare midt i kassen, og resten fandtes kun ved at trække inde i feltet.
*
* Abonnementet kommer fra et svar, prøven selv skriver. Serveren ville skulle hente
* kalenderen for at have et at vise, og en prøve, der går på nettet, er en prøve,
* der fejler en dag af en anden grund end sin egen.
*/
test('adresserne på integrationssiden kan ses helt på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.route('**/api/v1/calendar', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
connected: false,
calendars: [],
has_client: false,
read_only: true,
redirect_uri: 'http://localhost:8097/api/v1/calendar/callback',
subscriptions: [
{
id: '0198f000-0000-7000-8000-000000000001',
provider: 'ics',
account: 'kw@netic.dk',
url:
'https://calendar.google.com/calendar/ical/kw%40netic.dk/private-3f9a1c7e5b2d4a6f8c0e1d2b3a4f5e6d/basic.ics',
created_at: '2026-09-01T09:00:00+02:00'
}
]
})
})
);
// En iPhone i stående format. Bredden er det eneste, der betyder noget her.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/indstillinger/integrationer');
await expect(page.getByText('kw@netic.dk')).toBeVisible();
// `.content` og ikke dokumentet. Skallen holder selv siden på skærmens bredde,
// så documentElement måler 390 uanset hvad — det er indholdsspalten indeni, der
// ruller, og det er den, man skubber med tommelfingeren. En prøve på dokumentet
// består med fejlen i behold; den er prøvet.
const spill = await page.evaluate(() => {
const el = document.querySelector('.content');
const url = document.querySelector('.subs-list .url');
return {
content: el.scrollWidth - el.clientWidth,
past: Math.round(url.getBoundingClientRect().right) - document.documentElement.clientWidth
};
});
expect(spill.content).toBe(0);
expect(spill.past).toBeLessThanOrEqual(0);
// Og adresserne på siden står der helt. De lå i skrivebeskyttede <input>, som
// ikke kan ombryde: kassen var 306 px og indholdet 595, så resten af adressen
// fandtes kun ved at rulle inde i feltet — hvilket ingen opdager. Et felt, der
// er klippet, er et felt, hvor scrollWidth er større end clientWidth, og det er
// præcis det, der måles her.
//
// Felterne står ved navn og ikke som en forespørgsel på en klasse: en prøve, der
// måler `.field output` og finder nul, består — også den dag felterne er lavet om
// til noget andet klippet. Den skal fejle, hvis et af dem forsvinder.
const clipped = await page.evaluate(() =>
['feed', 'mail', 'hook', 'hookcurl', 'caldav']
.map((id) => {
const el = document.getElementById(id);
if (!el) return `${id}: findes ikke`;
if (el.scrollWidth - el.clientWidth > 1) return `${id}: ${el.scrollWidth} > ${el.clientWidth}`;
return null;
})
.filter(Boolean)
);
expect(clipped).toEqual([]);
// Selve teksten, ikke kun kassens mål: krogens adresse er den, der skal kunne
// læses af, hvis man taster den ind i en genvej på telefonen.
await expect(page.locator('#hook')).toHaveText(/^https?:\/\/.+\/inbound\/hook\/.+/);
expect(trouble).toEqual([]);
});
/**
* En indstilling kan findes ved at søge efter den.
*
* Indstillingerne er ti faner med en snes afsnit, og den eneste vej til et
* bestemt af dem var at huske, hvilken fane det lå under. Prøven går begge veje
* ind: feltet øverst i indstillingerne, og ⌘K, som ikke kunne andet end det, man
* selv har skrevet.
*
* "beacon" er valgt med vilje. Afsnittet hedder "Tælling af installationer" på
* dansk, så en søgning, der kun læste overskrifterne, ville ikke finde det — og
* "beacon" er præcis det ord, man kender det ved. Det er derfor indekset har et
* felt med søgeord, ingen får at se.
*/
test('en indstilling kan findes ved at søge efter den', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const find = page.getByPlaceholder('Søg i indstillinger');
await find.fill('beacon');
const hit = page.locator('.hits button', { hasText: 'Tælling af installationer' });
await expect(hit).toBeVisible();
// Hvor det ligger, står ved siden af hvad det hedder: halvdelen af det, man
// søgte efter, er svaret på hvilken fane.
await expect(hit).toContainText('Data');
await hit.click();
await expect(page).toHaveURL(/\/indstillinger\/data#beacon$/);
const section = page.locator('#beacon');
await expect(section.getByRole('heading', { name: 'Tælling af installationer' })).toBeVisible();
// Rullet derhen, ikke bare tegnet: afsnittet er det syvende på siden, så en
// side, der bare blev åbnet, ville vise toppen af den.
await expect(section).toBeInViewport();
// Feltet tømmer sig selv, når man er landet. En søgning, der bliver stående
// over det, man fandt, dækker det.
await expect(find).toHaveValue('');
// Og det, der ikke findes, siger det frem for at vise en tom liste.
await find.fill('hestevogn');
await expect(page.getByText('Ingen indstilling hedder det.')).toBeVisible();
// ⌘K spørger det samme indeks. Her er det den anden vej ind, og "webhook" er
// igen et ord, der ikke står i overskriften.
await page.goto('/');
// Knappen og ikke tastetrykket: ⌘K lyttes der efter i en effekt, så et tryk
// sendt lige efter goto kan nå frem, før siden er hydreret. Knappen venter
// Playwright på af sig selv — og den er også den, man har på en telefon.
await page.getByRole('button', { name: /Søg/ }).click();
await page.getByRole('dialog').getByRole('textbox').fill('webhook');
const row = page.getByRole('dialog').locator('li button', { hasText: 'Skub fra andre programmer' });
await expect(row).toBeVisible();
await expect(row).toContainText('Indstilling');
await row.click();
await expect(page).toHaveURL(/\/indstillinger\/integrationer#krog$/);
await expect(page.locator('#krog')).toBeInViewport();
expect(trouble).toEqual([]);
});
/**
* Et projekt kan deles med en, der allerede er her.
*
* Indtil nu kunne det kun deles ved at stave en e-mailadresse — altså ved at kende
* adressen på en kollega, man kender navnet på og sidder i samme rum som. Konti på
* en instans bliver kun til ved invitation, så der er ikke noget at beskytte ved at
* lade være med at vise dem for hinanden; det er adressebogen, funktionen har brug
* for.
*
* Prøven laver selv sin anden person frem for at låne den fra prøven ovenfor. Den
* kobling så jeg først, da jeg kørte den her alene: basen tømmes for hver kørsel,
* så en prøve, der kun består, når hele filen kører i rækkefølge, består ikke af
* sine egne grunde.
*
* Og turen forbi invitationen er ikke spild. Den viser skiftet: den samme person er
* først en ventende adresse, og bagefter et navn, der kan vælges.
*/
test('et projekt kan deles med en, der allerede har en konto', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Vinterhaven');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Vinterhaven' })).toBeVisible();
// Først den, der ikke er her endnu. Adressen er den eneste vej til dem.
await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('gartner@example.dk');
await page.getByRole('button', { name: 'Inviter' }).click();
// Og de bliver stående synligt, indtil de kommer — ellers er en invitation
// sendt i går usynlig, og så bliver den sendt igen.
const invited = page.locator('.members.invited');
await expect(invited.getByText('gartner@example.dk')).toBeVisible();
await expect(invited).toContainText('Inviteret, ikke kommet endnu');
const link = await page.locator('.link-out code').textContent();
expect(link).toContain('/invite?token=');
// Tom kontekst: en inviteret, der allerede er logget ind som den, der inviterede,
// er det ene tilfælde, der aldrig sker.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const guest = await context.newPage();
await guest.goto(link);
await guest.getByLabel('Navn', { exact: true }).fill('Gartner');
await guest.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await guest.getByRole('button', { name: 'Opret konto' }).click();
await expect(guest.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await context.close();
// Nu er de her. Et nyt projekt, og den samme person er blevet et navn, man vælger.
await page.goto('/');
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Drivhuset');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
await projectAction(page, 'Del');
const picker = page.locator('.addshare select').first();
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(1);
await picker.selectOption({ label: 'Gartner' });
await page.getByLabel('Rolle for den valgte').selectOption('viewer');
await page.locator('.addshare button', { hasText: 'Del' }).click();
const members = page.locator('.members').first();
await expect(members.locator('li', { hasText: 'Gartner' })).toContainText('Kan kun se');
// Og bagefter er de væk fra listen: en liste, der tilbyder at tilføje en, der
// allerede er der, er en liste, man gør det i ved et uheld.
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(0);
// Tilbagetrækningen af en invitation prøves i Go
// (TestAPendingInviteIsVisibleAndCanBeWithdrawn) og ikke her. Den krævede et
// spring tilbage til det første projekt, og delingspanelet står åbent på tværs
// af det spring — så klikket på "Del" lukkede det, jeg lige havde åbnet.
expect(trouble).toEqual([]);
});
+1 -5
View File
@@ -338,11 +338,7 @@ export const api = {
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
// É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}`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
@@ -5,7 +5,6 @@
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();
@@ -69,20 +68,7 @@
}, 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) => ({
@@ -122,9 +108,6 @@
function choose(item) {
open = false;
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
// op, den ER stedet.
if (item.kind === 'setting') return goto(item.id);
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
}
+1 -10
View File
@@ -210,7 +210,6 @@ 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',
@@ -221,7 +220,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, noter, projekter og indstillinger',
'nav.searchLong': 'Søg i opgaver og projekter',
'nav.noResults': 'Ingen resultater.',
'nav.showSidebar': 'Vis sidebjælken',
'nav.hideSidebar': 'Skjul sidebjælken',
@@ -302,8 +301,6 @@ 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',
@@ -908,12 +905,6 @@ export const da = {
'project.canEdit': 'Kan redigere',
'project.canView': 'Kan kun se',
'project.invite': 'Inviter',
'project.pickPerson': 'Vælg en person…',
'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',
+1 -10
View File
@@ -207,7 +207,6 @@ 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',
@@ -218,7 +217,7 @@ export const en = {
'nav.signOutConfirm': 'Click again to sign out',
'nav.main': 'Main menu',
'nav.search': 'Search',
'nav.searchLong': 'Search tasks, notes, projects and settings',
'nav.searchLong': 'Search tasks and projects',
'nav.noResults': 'Nothing found.',
'nav.showSidebar': 'Show the sidebar',
'nav.hideSidebar': 'Hide the sidebar',
@@ -299,8 +298,6 @@ 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',
@@ -902,12 +899,6 @@ export const en = {
'project.canEdit': 'Can edit',
'project.canView': 'Can only view',
'project.invite': 'Invite',
'project.pickPerson': 'Pick somebody…',
'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',
-140
View File
@@ -1,140 +0,0 @@
/**
* 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);
}
+1 -210
View File
@@ -11,10 +11,8 @@
* 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();
@@ -39,69 +37,6 @@
]);
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">
@@ -109,34 +44,6 @@
<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>
@@ -202,108 +109,9 @@
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);
@@ -343,8 +151,7 @@
.settings :global(.field input),
.settings :global(.field select),
.settings :global(.field textarea),
.settings :global(.field output) {
.settings :global(.field textarea) {
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface-sunken);
@@ -361,22 +168,6 @@
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. */
+10 -10
View File
@@ -276,7 +276,7 @@
*/
</script>
<section class="panel" id="profil">
<section class="panel">
<header>
<h2>{t('account.profile')}</h2>
<p class="hint">
@@ -332,7 +332,7 @@
</form>
</section>
<section class="panel" id="udseende">
<section class="panel">
<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 id="look">{t('account.look')}</h3>
<h3>{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 id="stoerrelser">{t('account.sizes')}</h3>
<h3>{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" id="links">
<section class="panel">
<header>
<h2>{t('link.title')}</h2>
<p class="hint">{t('link.hint')}</p>
@@ -464,7 +464,7 @@
{/if}
</section>
<section class="panel" id="adgangskode">
<section class="panel">
<header>
<h2>{t('account.password')}</h2>
<p class="hint">
@@ -506,7 +506,7 @@
</form>
</section>
<section class="panel" id="to-faktor">
<section class="panel">
<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" id="passkey">
<section class="panel">
<header>
<h2>{t('passkey.title')}</h2>
<p class="hint">{t('passkey.hint')}</p>
@@ -655,7 +655,7 @@
{/if}
</section>
<section class="panel" id="enheder">
<section class="panel">
<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" id="stoette">
<section class="panel">
<header>
<h2>{t('account.support')}</h2>
<p class="hint">{t('account.supportHint')}</p>
+2 -2
View File
@@ -83,7 +83,7 @@
}
</script>
<section class="panel" id="ai">
<section class="panel">
<header>
<h2>{t('ai.title')}</h2>
<p class="hint">
@@ -157,7 +157,7 @@
</section>
{#if settings?.provider && settings?.has_key}
<section class="panel" id="ugentlig">
<section class="panel">
<header>
<h2>{t('ai.weekly')}</h2>
<p class="hint">{t('ai.weeklyHint')}</p>
@@ -128,7 +128,7 @@
const expires = shortDate;
</script>
<section class="panel" id="invitation">
<section class="panel">
<header>
<h2>{t('users.invite')}</h2>
<p class="hint">
@@ -163,7 +163,7 @@
</section>
{#if invites.length}
<section class="panel" id="afventende">
<section class="panel">
<header>
<h2>{t('users.pending')}</h2>
<p class="hint">
@@ -191,7 +191,7 @@
</section>
{/if}
<section class="panel" id="konti">
<section class="panel">
<header>
<h2>{t('users.accounts')}</h2>
<p class="hint">{t('users.accountsHint')}</p>
@@ -234,7 +234,7 @@
}
</script>
<section class="panel" id="import">
<section class="panel">
<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" id="faerdige">
<section class="panel">
<header>
<h2>{t('done.title')}</h2>
<p class="hint">{t('data.doneHint')}</p>
@@ -289,7 +289,7 @@
</div>
</section>
<section class="panel" id="papirkurv">
<section class="panel">
<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" id="sikkerhedskopier">
<section class="panel">
<header>
<h2>{t('data.backups')}</h2>
<p class="hint">
@@ -380,7 +380,7 @@
</section>
{/if}
<section class="panel" id="eksport">
<section class="panel">
<header>
<h2>{t('data.export')}</h2>
<p class="hint">
@@ -428,7 +428,7 @@
{/if}
</section>
<section class="panel" id="skabeloner">
<section class="panel">
<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" id="beacon">
<section class="panel">
<header>
<h2>{t('beacon.title')}</h2>
<p class="hint">{t('beacon.hint')}</p>
@@ -28,7 +28,7 @@
</script>
<section class="panel" id="fejl">
<section class="panel">
<header>
<h2>{t('errors.title')}</h2>
<p class="hint">
@@ -106,7 +106,7 @@
}
</script>
<section class="panel" id="historik">
<section class="panel">
<header>
<h2>{t('history.title')}</h2>
<p class="hint">
@@ -416,7 +416,7 @@
}
</script>
<section class="panel" id="gmail">
<section class="panel">
<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. -->
<output id="redirect">{client.redirect_uri}</output>
<input id="redirect" readonly value={client.redirect_uri} />
</div>
<form onsubmit={saveClient}>
@@ -520,7 +520,7 @@
{/if}
</section>
<section class="panel" id="kalender">
<section class="panel">
<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>
<output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
<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 id="abonnementer">{t('int.subscriptions')}</h3>
<h3>{t('int.subscriptions')}</h3>
<p class="hint">{t('int.subscriptionsHint')}</p>
{#if calendar.subscriptions?.length}
@@ -643,7 +643,7 @@
{/if}
</section>
<section class="panel" id="kalenderfeed">
<section class="panel">
<header>
<h2>{t('int.feed')}</h2>
<p class="hint">
@@ -653,7 +653,7 @@
<div class="field">
<label for="feed">{t('int.address')}</label>
<output id="feed" class="mono">{feedURL}</output>
<input id="feed" class="mono" value={feedURL} readonly />
</div>
<div class="row">
@@ -665,7 +665,7 @@
</p>
</section>
<section class="panel" id="mail-til-opgave">
<section class="panel">
<header>
<h2>{t('int.mailToTask')}</h2>
<p class="hint">
@@ -681,7 +681,7 @@
<div class="field">
<label for="mail">{t('int.yourAddress')}</label>
<output id="mail" class="mono">{mailAddress}</output>
<input id="mail" class="mono" value={mailAddress} readonly />
</div>
<div class="row">
@@ -690,7 +690,7 @@
</div>
</section>
<section class="panel" id="krog">
<section class="panel">
<header>
<h2>{t('int.hookToTask')}</h2>
<p class="hint">
@@ -700,7 +700,7 @@
<div class="field">
<label for="hook">{t('int.yourHook')}</label>
<output id="hook" class="mono">{hookURL}</output>
<input id="hook" class="mono" value={hookURL} readonly />
<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>
<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
<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
>
</div>
@@ -722,7 +722,7 @@
</div>
</section>
<section class="panel" id="caldav-server">
<section class="panel">
<header>
<h2>{t('int.caldav')}</h2>
<p class="hint">
@@ -732,7 +732,7 @@
<div class="field">
<label for="caldav">{t('int.server')}</label>
<output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
<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" id="postkasser">
<section class="panel">
<header>
<h2>{t('int.mailboxes')}</h2>
<p class="hint">{t('int.mailboxesHint')}</p>
@@ -817,7 +817,7 @@
color: var(--ink-muted);
}
.setup output {
.setup input[readonly] {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-muted);
@@ -884,38 +884,4 @@
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" id="dagens-plan">
<section class="panel">
<header>
<h2>{t('ai.plan')}</h2>
<p class="hint">{t('ai.planHint')}</p>
@@ -252,7 +252,7 @@
{/if}
</section>
<section class="panel" id="push">
<section class="panel">
<header>
<h2>{t('push.title')}</h2>
<p class="hint">
@@ -296,7 +296,7 @@
{/if}
</section>
<section class="panel" id="seneste">
<section class="panel">
<header>
<h2>{t('push.recent')}</h2>
{#if unread > 0}
@@ -335,7 +335,7 @@
</section>
{#if version}
<section class="panel" id="version">
<section class="panel">
<header>
<h2>{t('push.version')}</h2>
</header>
@@ -93,7 +93,7 @@
const expired = (iso) => iso && new Date(iso) < new Date();
</script>
<section class="panel" id="tokens">
<section class="panel">
<header>
<h2>{t('tokens.title')}</h2>
<p class="hint">
+3 -15
View File
@@ -1279,7 +1279,7 @@
lovet i en besked, der ikke kom af sted. -->
<p class="invitelink">
{t('notes.inviteNoMail')}
<output>{shareInviteLink}</output>
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
</p>
{/if}
</section>
@@ -2216,23 +2216,11 @@
line-height: 1.5;
}
/* <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;
.invitelink input {
width: 100%;
margin-top: 2px;
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;
padding: 2px var(--s1);
}
.addshare {
+7 -118
View File
@@ -41,13 +41,6 @@
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);
@@ -108,7 +101,7 @@
$effect(() => {
if (showShare && project) {
api.listMembers(project.id).then(readMembers);
api.listMembers(project.id).then((r) => (members = r.members));
}
});
@@ -519,46 +512,23 @@
}
}
// É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) {
async function invite(event) {
event.preventDefault();
inviteError = '';
inviteLink = '';
try {
const result = await api.invite(project.id, who, role);
const result = await api.invite(project.id, inviteEmail, inviteRole);
inviteEmail = '';
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;
}
readMembers(await api.listMembers(project.id));
members = (await api.listMembers(project.id)).members;
} 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 = '';
}
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
@@ -795,7 +765,7 @@
class="remove"
onclick={async () => {
await api.removeMember(project.id, member.user_id);
readMembers(await api.listMembers(project.id));
members = (await api.listMembers(project.id)).members;
}}
aria-label={t('project.removeMember', { name: member.name })}>×</button
>
@@ -804,60 +774,7 @@
{/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}
{#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}
@@ -1479,34 +1396,6 @@
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;
}
.byemail-hint {
margin: 0;
font-size: var(--text-xs);
color: var(--ink-muted);
}
.avatar {
width: 22px;
height: 22px;