Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1d27f8ca | ||
|
|
cab4e703f5 | ||
|
|
43f8c0ea85 | ||
|
|
8ecefbbdc2 | ||
|
|
6b4448b245 | ||
|
|
e46aaa462a | ||
|
|
98bfa216d6 |
@@ -2049,6 +2049,36 @@ paths:
|
|||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/projects/{projectID}/sections/reorder:
|
||||||
|
parameters:
|
||||||
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
|
post:
|
||||||
|
tags: [projects]
|
||||||
|
summary: Set the order the project's sections appear in. Editors and owners.
|
||||||
|
description: |
|
||||||
|
The whole list at once, for the same reasons as `/projects/reorder`: a
|
||||||
|
project has a handful of sections, so sending the order you want is
|
||||||
|
simpler than midpoints and cannot land half-applied.
|
||||||
|
|
||||||
|
Ids belonging to another project are skipped rather than refused.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ids]
|
||||||
|
properties:
|
||||||
|
ids:
|
||||||
|
type: array
|
||||||
|
maxItems: 500
|
||||||
|
items: { type: string }
|
||||||
|
description: Section ids, top to bottom.
|
||||||
|
responses:
|
||||||
|
"204": { description: Reordered. }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
/api/v1/sections/{sectionID}:
|
/api/v1/sections/{sectionID}:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
||||||
|
|||||||
@@ -231,6 +231,33 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
|
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if getErr == nil {
|
if getErr == nil {
|
||||||
|
// The task decides, not the URL.
|
||||||
|
//
|
||||||
|
// The check above is about the project the *client named*, and a client
|
||||||
|
// names both halves of this address. Asking the project for permission and
|
||||||
|
// then taking the task id from the same string ties nothing together: a
|
||||||
|
// viewer on a shared project could name a project of their own — the Inbox,
|
||||||
|
// which everybody owns and may edit — and put the shared task's id in the
|
||||||
|
// filename. The project check passed on the Inbox and the write landed on a
|
||||||
|
// task they may only read.
|
||||||
|
//
|
||||||
|
// GetTask does not close it either: it answers "may this person see it",
|
||||||
|
// and a viewer may. `CanEdit` is the question, and it is the same one
|
||||||
|
// handleUpdateTask asks — the API door has always asked it.
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// And it has to live where the address says it does. Without this an editor
|
||||||
|
// on two projects can reach either through the other's URL — nothing is
|
||||||
|
// escalated by that on its own, but a URL allowed to lie about where a
|
||||||
|
// resource lives is how the case above got in.
|
||||||
|
if existing.ProjectID != projectID {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// An update. Only the fields a task client can express are touched;
|
// An update. Only the fields a task client can express are touched;
|
||||||
// everything else the task carries stays as it is.
|
// everything else the task carries stays as it is.
|
||||||
content := parsed.Summary
|
content := parsed.Summary
|
||||||
|
|||||||
@@ -250,6 +250,116 @@ func (ts *testServer) apiToken(t *testing.T, userID string) string {
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The promise of sharing has to hold at every door, not just the API's.
|
||||||
|
//
|
||||||
|
// TestViewerCannotMutateThroughTheAPI asserts a viewer cannot write, and it
|
||||||
|
// asserts it against /api/v1. CalDAV is a second door into the same tasks, and it
|
||||||
|
// authorised differently: it checked the caller's standing in the project *named
|
||||||
|
// in the URL* and then took the task id from the URL as well. Nothing tied the
|
||||||
|
// two together.
|
||||||
|
//
|
||||||
|
// So a viewer on somebody else's project could name a project of their own —
|
||||||
|
// their Inbox, which everybody owns and everybody may edit — and put the other
|
||||||
|
// project's task id in the filename. The project check passed on the Inbox, and
|
||||||
|
// the write landed on a task in a project they may only read.
|
||||||
|
func TestAViewerCannotWriteThroughCalDAV(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.newUser(t, "viewer@example.dk", "Viewer")
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "en opgave", "project_id": projectID,
|
||||||
|
})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
if resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "viewer@example.dk", "role": "viewer",
|
||||||
|
}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
them, err := ts.db.UserByEmail(t.Context(), "viewer@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, them.ID)
|
||||||
|
|
||||||
|
// Their own Inbox: a project they own and may edit, and the one every account
|
||||||
|
// has. It is what makes the URL check pass.
|
||||||
|
projects, err := ts.db.ListProjects(t.Context(), them.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var inbox string
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.IsInbox {
|
||||||
|
inbox = p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inbox == "" {
|
||||||
|
t.Fatal("the viewer has no inbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||||
|
"\r\nSUMMARY:kapret\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
resp := ts.dav(t, "PUT", "/caldav/"+them.ID+"/"+inbox+"/"+taskID+".ics",
|
||||||
|
token, them.Email, vtodo)
|
||||||
|
if resp.StatusCode < 400 {
|
||||||
|
t.Errorf("a viewer wrote through CalDAV: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the task is as its owner left it. The status alone is not the assertion:
|
||||||
|
// the question is whether anything moved.
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if after["content"] != "en opgave" {
|
||||||
|
t.Errorf("the task was renamed by a viewer: %v", after["content"])
|
||||||
|
}
|
||||||
|
if after["completed"] != false {
|
||||||
|
t.Error("the task was completed by a viewer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task may only be addressed under the project it is actually in.
|
||||||
|
//
|
||||||
|
// The same hole seen from the other side: an editor on two projects could reach a
|
||||||
|
// task in one of them through the other's URL. Nothing is escalated by it — they
|
||||||
|
// may edit both — but it is the check that was missing, and a URL that lies about
|
||||||
|
// where a resource lives is how the viewer case got in.
|
||||||
|
func TestCalDAVRefusesATaskFromAnotherProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, user.ID)
|
||||||
|
|
||||||
|
_, one := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Et"})
|
||||||
|
_, two := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "To"})
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "hører til Et", "project_id": one["id"].(string),
|
||||||
|
})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||||
|
"\r\nSUMMARY:flyttet i smug\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
resp := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+two["id"].(string)+"/"+taskID+".ics",
|
||||||
|
token, user.Email, vtodo)
|
||||||
|
if resp.StatusCode < 400 {
|
||||||
|
t.Errorf("a task was written under the wrong project: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if after["content"] != "hører til Et" {
|
||||||
|
t.Errorf("the task changed: %v", after["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalDAVRequiresCredentials(t *testing.T) {
|
func TestCalDAVRequiresCredentials(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
@@ -562,6 +672,48 @@ func TestAIKeyIsNeverReturned(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connecting something else must not delete the AI provider.
|
||||||
|
//
|
||||||
|
// Reported from use as "the AI settings are not saved": they were saved, and then
|
||||||
|
// taken. `user_settings` had `user_id` as its whole primary key, and the upsert
|
||||||
|
// renamed `scope` on conflict — so the `ai`, `gmail` and `calendar` scopes shared
|
||||||
|
// one row and each write deleted the other two. Every write returned 204.
|
||||||
|
//
|
||||||
|
// Through the handlers, because that is where it was seen. The shape is asserted
|
||||||
|
// against the store as well: see TestOnePersonCanHaveSettingsForMoreThanOneThing.
|
||||||
|
func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||||
|
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
|
||||||
|
})
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// What the calendar and the mailbox handlers do when a handshake starts: write
|
||||||
|
// their own scope for the same person.
|
||||||
|
for _, scope := range []string{"calendar", "gmail"} {
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user.ID, scope,
|
||||||
|
map[string]any{"pkce_state": "s-" + scope}); err != nil {
|
||||||
|
t.Fatalf("save %s: %v", scope, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||||
|
if body["provider"] != "anthropic" {
|
||||||
|
t.Errorf("the provider is %v after connecting two other things, want anthropic", body["provider"])
|
||||||
|
}
|
||||||
|
if body["model"] != "claude-sonnet-5" {
|
||||||
|
t.Errorf("the model is %v", body["model"])
|
||||||
|
}
|
||||||
|
if body["has_key"] != true {
|
||||||
|
t.Error("the key was taken with it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGmailSettings(t *testing.T) {
|
func TestGmailSettings(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
|
|||||||
@@ -356,6 +356,34 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleReorderSections sets the order of the sections inside one project.
|
||||||
|
//
|
||||||
|
// Under /projects/{projectID}/ rather than beside the other section routes, which
|
||||||
|
// hang off a section id: the thing being changed here is the project's list, not
|
||||||
|
// any one section in it. That also puts it behind the same project permission
|
||||||
|
// check as everything else in this subtree, instead of resolving a section to its
|
||||||
|
// project one id at a time and hoping they all named the same one.
|
||||||
|
func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reorderProjectsRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The same ceiling as the projects list, and a bound on the transaction rather
|
||||||
|
// than an opinion about how many sections a project may have.
|
||||||
|
if len(req.IDs) > 500 {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil {
|
||||||
|
s.internal(w, r, "reorder sections", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "section.updated", nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// sectionAccess resolves a section to its project and checks the caller may edit
|
// sectionAccess resolves a section to its project and checks the caller may edit
|
||||||
// it. Sections have no permissions of their own — they belong to a project, and
|
// it. Sections have no permissions of their own — they belong to a project, and
|
||||||
// that is where access is decided.
|
// that is where access is decided.
|
||||||
|
|||||||
@@ -327,6 +327,71 @@ func TestAProjectCanHaveSeveralSections(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sections can be dragged into another order, and the order is the project's.
|
||||||
|
//
|
||||||
|
// The list is what a project's plan *is* — "Planlægning" before "I gang" is the
|
||||||
|
// order the work happens in — and until this endpoint existed the only way to
|
||||||
|
// change it was to delete a section and make it again, which moves its tasks to
|
||||||
|
// the unsectioned area on the way past.
|
||||||
|
func TestSectionsCanBeReordered(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
|
||||||
|
id := project["id"].(string)
|
||||||
|
|
||||||
|
ids := map[string]string{}
|
||||||
|
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
|
||||||
|
for _, name := range names {
|
||||||
|
_, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections", map[string]any{"name": name})
|
||||||
|
ids[name] = body["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"Til gennemsyn", "Planlægning", "I gang"}
|
||||||
|
order := make([]string, len(want))
|
||||||
|
for i, name := range want {
|
||||||
|
order[i] = ids[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||||
|
map[string]any{"ids": order})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder: status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
|
||||||
|
got, _ := listed["sections"].([]any)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("the project has %d sections, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i, raw := range got {
|
||||||
|
if name := raw.(map[string]any)["name"]; name != want[i] {
|
||||||
|
t.Errorf("section %d is %v, want %q", i, name, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A section belonging to somebody else's project is not moved by naming it
|
||||||
|
// here. The ids in the body are not checked one by one — the WHERE clause is
|
||||||
|
// what refuses them — so this is the assertion that it actually does.
|
||||||
|
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Sommerhus"})
|
||||||
|
otherID := other["id"].(string)
|
||||||
|
_, foreign := ts.do(t, "POST", "/api/v1/projects/"+otherID+"/sections",
|
||||||
|
map[string]any{"name": "Havearbejde"})
|
||||||
|
foreignID := foreign["id"].(string)
|
||||||
|
|
||||||
|
resp, _ = ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||||
|
map[string]any{"ids": []string{foreignID}})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder with a foreign id: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, stillThere := ts.do(t, "GET", "/api/v1/projects/"+otherID+"/sections", nil)
|
||||||
|
elsewhere, _ := stillThere["sections"].([]any)
|
||||||
|
if len(elsewhere) != 1 || elsewhere[0].(map[string]any)["name"] != "Havearbejde" {
|
||||||
|
t.Errorf("the other project's section did not survive: %v", elsewhere)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A project can be created straight into a group.
|
// A project can be created straight into a group.
|
||||||
//
|
//
|
||||||
// `group_id` was declared on the request type, documented on it, and applied by
|
// `group_id` was declared on the request type, documented on it, and applied by
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type Server struct {
|
|||||||
// the endpoints that check a secret are limited separately and more tightly
|
// the endpoints that check a secret are limited separately and more tightly
|
||||||
// than the rest of the API.
|
// than the rest of the API.
|
||||||
loginLimiter *limiter
|
loginLimiter *limiter
|
||||||
|
mailLimiter *limiter
|
||||||
resetLimiter *limiter
|
resetLimiter *limiter
|
||||||
// Second factors are counted against the *account*, not the address. An
|
// Second factors are counted against the *account*, not the address. An
|
||||||
// address bucket is defeated by anybody with more than one address, which is
|
// address bucket is defeated by anybody with more than one address, which is
|
||||||
@@ -85,6 +86,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||||
hub: realtime.NewHub(log),
|
hub: realtime.NewHub(log),
|
||||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||||
|
// Generous on purpose: the caller is the mail server, one address delivering
|
||||||
|
// everything, and a personal instance gets a handful of forwarded mails a
|
||||||
|
// day. Sixty a minute never touches real delivery and still puts a ceiling
|
||||||
|
// on the one public write path that has none.
|
||||||
|
mailLimiter: newLimiter(60, time.Minute),
|
||||||
resetLimiter: newLimiter(5, time.Hour),
|
resetLimiter: newLimiter(5, time.Hour),
|
||||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||||
// their own code gets it right on the first or second try; ten in an hour is
|
// their own code gets it right on the first or second try; ten in an hour is
|
||||||
@@ -122,7 +128,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
|
|
||||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||||
// authenticated by the token in the recipient address.
|
// authenticated by the token in the recipient address.
|
||||||
r.Post("/inbound/mail", s.handleInboundMail)
|
//
|
||||||
|
// Rate limited for the same reason the beacon is: it is reachable by anybody
|
||||||
|
// who can route to this instance. It is the weaker case of the two — the
|
||||||
|
// beacon writes a row for a stranger, this one writes nothing without a
|
||||||
|
// 256-bit address it has to already know — but an address is shared with mail
|
||||||
|
// servers and printed on a settings page, and once it is known there was no
|
||||||
|
// ceiling at all on what could be made with it.
|
||||||
|
r.Method(http.MethodPost, "/inbound/mail",
|
||||||
|
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
|
||||||
|
|
||||||
// MCP with the token in the query string, for clients that cannot send a
|
// MCP with the token in the query string, for clients that cannot send a
|
||||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||||
@@ -426,6 +440,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireProject(store.RoleEditor))
|
r.Use(s.requireProject(store.RoleEditor))
|
||||||
r.Post("/sections", s.handleCreateSection)
|
r.Post("/sections", s.handleCreateSection)
|
||||||
|
r.Post("/sections/reorder", s.handleReorderSections)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
|||||||
+15
-1
@@ -32,6 +32,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/safedial"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Subscription struct {
|
type Subscription struct {
|
||||||
@@ -101,7 +103,19 @@ func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) e
|
|||||||
req.Header.Set("Urgency", "normal")
|
req.Header.Set("Urgency", "normal")
|
||||||
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
|
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
|
||||||
|
|
||||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
// safedial, not a plain client. The endpoint comes from the browser's
|
||||||
|
// subscription, which is to say from whoever is signed in, and this server
|
||||||
|
// POSTs to it on a schedule they choose — set a reminder a minute out and the
|
||||||
|
// job goes again every minute.
|
||||||
|
//
|
||||||
|
// The subscribe handler already refuses a private address, and that check is
|
||||||
|
// not enough on its own: it runs on the URL as written, and a name it approves
|
||||||
|
// can answer 127.0.0.1 the next time it is looked up. safedial checks the
|
||||||
|
// *resolved* address at connect time, which is the only moment the address
|
||||||
|
// that will actually be used is known — and it checks every redirect hop the
|
||||||
|
// same way. The parse-time check stays, because it says no while somebody is
|
||||||
|
// looking at the screen.
|
||||||
|
resp, err := safedial.Client(15 * time.Second).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package push
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A subscription must not be able to point the server at its own network.
|
||||||
|
//
|
||||||
|
// The endpoint is whatever the browser handed over, which is to say whatever the
|
||||||
|
// person signed in handed over, and this server POSTs to it on a schedule they
|
||||||
|
// choose. The subscribe handler refuses a private address when it is written
|
||||||
|
// down — and that check runs on the URL as written, so a name it approves can
|
||||||
|
// answer 127.0.0.1 the next time it is looked up. This is the other half: the
|
||||||
|
// address that will actually be dialled, checked at the moment of dialling.
|
||||||
|
func TestSendRefusesToReachInside(t *testing.T) {
|
||||||
|
reached := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reached = true
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
// httptest listens on 127.0.0.1, which is exactly the destination in question.
|
||||||
|
if !strings.Contains(srv.URL, "127.0.0.1") {
|
||||||
|
t.Skipf("the test server is not on loopback: %s", srv.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Send(context.Background(), testSubscription(t, srv.URL), Payload{Title: "hej"}, testVAPID(t))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send reached a loopback address without complaining")
|
||||||
|
}
|
||||||
|
if reached {
|
||||||
|
t.Error("the request arrived: the wall let it through and only the answer failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscription(t *testing.T, endpoint string) Subscription {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(auth); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return Subscription{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
P256dh: b64.EncodeToString(key.PublicKey().Bytes()),
|
||||||
|
Auth: b64.EncodeToString(auth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVAPID(t *testing.T) VAPID {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
|
||||||
|
}
|
||||||
@@ -195,10 +195,14 @@ func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
||||||
`"email":"kw@nolimit.dk","seen":["abc","def"],"project_id":"p-1"}`
|
`"email":"kw@nolimit.dk","seen":["abc","def"],"project_id":"p-1"}`
|
||||||
|
// The conflict target is (user_id, scope) since 0025 — before that the table
|
||||||
|
// held one row per person and the upsert renamed its scope, which is the bug
|
||||||
|
// 0025 exists to undo. Nothing conflicts here; the clause only has to be legal
|
||||||
|
// against the schema this test is running on.
|
||||||
if _, err := db.ExecContext(context.Background(), `
|
if _, err := db.ExecContext(context.Background(), `
|
||||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
VALUES (?, 'gmail', ?, 0)
|
VALUES (?, 'gmail', ?, 0)
|
||||||
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
|
ON CONFLICT (user_id, scope) DO UPDATE SET values_json = excluded.values_json`,
|
||||||
userID, values); err != nil {
|
userID, values); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- One row per person *and scope*, which is what the table always meant.
|
||||||
|
--
|
||||||
|
-- `user_settings` was created with `user_id` as its whole primary key, and its
|
||||||
|
-- upsert rewrote `scope` on conflict. So a person had exactly one settings row,
|
||||||
|
-- and whichever scope was written last was the only one that survived: saving an
|
||||||
|
-- AI provider deleted the Gmail connection, and connecting a calendar deleted the
|
||||||
|
-- AI provider. Nothing reported it — the write succeeded, and the loss was in the
|
||||||
|
-- row it landed on.
|
||||||
|
--
|
||||||
|
-- This was seen coming twice. Migration 0011 gave the instance's settings their
|
||||||
|
-- own table partly for this reason and wrote the warning down in as many words;
|
||||||
|
-- 0014 moved mailboxes out and said the warning "was nearly walked into a second
|
||||||
|
-- time". The third time was the calendar, which added a `calendar` scope beside
|
||||||
|
-- `ai` and `gmail` — and walked in.
|
||||||
|
--
|
||||||
|
-- The lesson worth keeping: a warning in a migration is read by whoever is editing
|
||||||
|
-- *that* migration, and nobody was. The shape is fixed here so there is nothing
|
||||||
|
-- left to warn about.
|
||||||
|
--
|
||||||
|
-- SQLite cannot change a primary key in place, so the table is rebuilt. Existing
|
||||||
|
-- rows are copied as they are: each person has at most one, and it holds whatever
|
||||||
|
-- scope won last. What the earlier writes lost is already gone and cannot be
|
||||||
|
-- recovered here — the values were overwritten, not orphaned.
|
||||||
|
CREATE TABLE user_settings_new (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'general',
|
||||||
|
values_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO user_settings_new (user_id, scope, values_json, updated_at)
|
||||||
|
SELECT user_id, scope, values_json, updated_at FROM user_settings;
|
||||||
|
|
||||||
|
DROP TABLE user_settings;
|
||||||
|
|
||||||
|
ALTER TABLE user_settings_new RENAME TO user_settings;
|
||||||
@@ -391,11 +391,15 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// On (user_id, scope), which is the key since 0025. It used to be on `user_id`
|
||||||
|
// alone and set `scope = excluded.scope` — so writing one scope did not add a
|
||||||
|
// row beside the others, it took theirs and renamed it. The `ai`, `gmail` and
|
||||||
|
// `calendar` settings each deleted the other two, and the write reported
|
||||||
|
// success every time.
|
||||||
_, err = db.ExecContext(ctx, `
|
_, err = db.ExecContext(ctx, `
|
||||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id, scope) DO UPDATE SET
|
||||||
scope = excluded.scope,
|
|
||||||
values_json = excluded.values_json,
|
values_json = excluded.values_json,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
userID, scope, string(raw), time.Now().Unix())
|
userID, scope, string(raw), time.Now().Unix())
|
||||||
|
|||||||
@@ -646,6 +646,35 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReorderSections writes the order of one project's sections, top to bottom.
|
||||||
|
//
|
||||||
|
// The whole list at once, the same as ReorderProjects and for the same reasons: a
|
||||||
|
// project has a handful of sections, so writing 0, 1, 2 … is one small
|
||||||
|
// transaction that cannot run out of precision, and a list is impossible to get
|
||||||
|
// into the half-ordered state a sequence of individual moves leaves behind when
|
||||||
|
// one of them fails.
|
||||||
|
//
|
||||||
|
// Scoped to the project rather than to a user. A section has no owner of its own —
|
||||||
|
// it belongs to a project, and whether the caller may rearrange it is a question
|
||||||
|
// about that project, which the handler has already asked. Ids belonging to some
|
||||||
|
// other project are skipped by the WHERE clause rather than refused, so a stale
|
||||||
|
// list from a client that has just moved a section elsewhere cannot rearrange a
|
||||||
|
// project the caller was never looking at.
|
||||||
|
func (db *DB) ReorderSections(ctx context.Context, projectID string, ids []string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE sections SET sort_order = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||||
|
float64(i), now, id, projectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SectionByName resolves a "/name" from quick add within one project.
|
// SectionByName resolves a "/name" from quick add within one project.
|
||||||
//
|
//
|
||||||
// Case-insensitive, and scoped to the project the task is landing in — a section
|
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||||
|
|||||||
@@ -124,3 +124,65 @@ func sealedStore(t *testing.T) (*DB, string) {
|
|||||||
}
|
}
|
||||||
return db, user.ID
|
return db, user.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Three integrations, one person, and none of them may delete the others.
|
||||||
|
//
|
||||||
|
// `user_settings` had `user_id` as its whole primary key and an upsert that
|
||||||
|
// rewrote `scope` on conflict, so every person had exactly one settings row and
|
||||||
|
// the last write won the lot: saving an AI provider deleted the Gmail connection,
|
||||||
|
// and connecting a calendar deleted the AI provider. The write reported success
|
||||||
|
// each time, which is why it was reported as "the AI settings are not saved"
|
||||||
|
// rather than as "connecting the calendar deleted them".
|
||||||
|
//
|
||||||
|
// Written against the store rather than the handlers, because the shape was the
|
||||||
|
// bug: every caller was correct on its own.
|
||||||
|
func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
want := map[string]map[string]any{
|
||||||
|
"ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"},
|
||||||
|
"gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"},
|
||||||
|
"calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"},
|
||||||
|
}
|
||||||
|
// In this order on purpose: the AI settings are written first and read last,
|
||||||
|
// so a scope that steals the row is caught by the one written earliest.
|
||||||
|
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
||||||
|
if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil {
|
||||||
|
t.Fatalf("save %s: %v", scope, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
||||||
|
got, err := db.UserSettings(ctx, userID, scope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", scope, err)
|
||||||
|
}
|
||||||
|
for key, value := range want[scope] {
|
||||||
|
if got[key] != value {
|
||||||
|
t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a second write to one scope updates it rather than adding another row —
|
||||||
|
// the upsert still has to upsert.
|
||||||
|
if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var rows int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rows != 3 {
|
||||||
|
t.Errorf("the person has %d settings rows, want 3", rows)
|
||||||
|
}
|
||||||
|
back, err := db.UserSettings(ctx, userID, "ai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back["provider"] != "openai" {
|
||||||
|
t.Errorf("the second write did not land: %v", back["provider"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+465
-81
@@ -6,12 +6,12 @@
|
|||||||
<title>verdande — self-hosted tasks, shared</title>
|
<title>verdande — self-hosted tasks, shared</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
|
content="Self-hosted tasks, notes and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, a CalDAV server, notes in Markdown, passkeys, and an MCP connector for Claude."
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#111312" />
|
<meta name="theme-color" content="#111312" />
|
||||||
|
|
||||||
<meta property="og:title" content="verdande" />
|
<meta property="og:title" content="verdande" />
|
||||||
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
|
<meta property="og:description" content="Self-hosted tasks, notes and projects, shared. One binary, SQLite inside it." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content="screenshots/today.webp" />
|
<meta property="og:image" content="screenshots/today.webp" />
|
||||||
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
||||||
@@ -76,10 +76,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: fixed;
|
/* In the bar now rather than floating over the corner. Fixed, it sat
|
||||||
top: 1rem;
|
on top of whatever happened to scroll under it — on a narrow window
|
||||||
right: 1rem;
|
that was the first screenshot's own corner. */
|
||||||
z-index: 10;
|
flex: none;
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -141,10 +141,86 @@
|
|||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- the bar that stays -------------------------------------------
|
||||||
|
|
||||||
|
A page this long had its only way out at the very top: somebody who
|
||||||
|
read as far as "What it will not do" had to scroll back past nine
|
||||||
|
screenshots to reach the documentation. The bar is the way out from
|
||||||
|
wherever you are.
|
||||||
|
|
||||||
|
`sticky` and not `fixed`: sticky keeps its space in the flow, so
|
||||||
|
nothing below has to be pushed down by a number that then has to be
|
||||||
|
kept in step with the bar's own height.
|
||||||
|
|
||||||
|
Full width, with the wrap *inside* it. Put the other way round, the
|
||||||
|
background would end where the column ends and the page would scroll
|
||||||
|
past it on both sides. */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--ground);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 560;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-brand .rune {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links a {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links a:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The three in-page links go first on a narrow screen: they are the ones
|
||||||
|
a phone can reach by scrolling anyway. Documentation and Source cannot
|
||||||
|
be reached any other way, so they stay. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bar-links .inpage {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Or the bar covers the heading you just jumped to. */
|
||||||
|
:target {
|
||||||
|
scroll-margin-top: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- hero --- */
|
/* --- hero --- */
|
||||||
|
|
||||||
header {
|
.hero {
|
||||||
padding: 6rem 0 4rem;
|
padding: 5rem 0 3.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +248,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.norn {
|
.norn {
|
||||||
margin: 2rem auto 0;
|
margin: 0 auto 1.75rem;
|
||||||
max-width: 34rem;
|
max-width: 34rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
@@ -289,44 +365,104 @@
|
|||||||
|
|
||||||
/* --- features --- */
|
/* --- features --- */
|
||||||
|
|
||||||
|
/* A hairline between sections rather than under every heading: the page
|
||||||
|
is one column, and the line says where one subject stops. */
|
||||||
section {
|
section {
|
||||||
padding: 3rem 0;
|
padding: 3.5rem 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin: 0 0 2rem;
|
margin: 0 0 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A short accent rule in front of every h2. The sections had nothing to
|
||||||
|
mark them but their own words, and at a glance the page read as one
|
||||||
|
long run of paragraphs. */
|
||||||
|
h2::before {
|
||||||
|
content: '';
|
||||||
|
flex: none;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform: translateY(-0.35em);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three groups rather than eighteen cards in a row. Grouped, the list is
|
||||||
|
read as three answers — what it does, what it talks to, what it
|
||||||
|
promises — and any of the three can be skipped. */
|
||||||
|
.group-label {
|
||||||
|
margin: 2.5rem 0 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 560;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards with their own edges, not cells in one table.
|
||||||
|
The table shared a hairline grid, which meant the last row had to be
|
||||||
|
full or it was a hole in the drawing — and it was a hole, for as long
|
||||||
|
as there were ten of them. A card that stands alone on its row is a
|
||||||
|
card; an empty cell is a mistake. */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||||
gap: 1px;
|
gap: 0.875rem;
|
||||||
background: var(--line);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
background: var(--ground);
|
background: var(--surface);
|
||||||
padding: 1.5rem;
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.125rem 1.25rem 1.25rem;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell h3 {
|
.cell h3 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.4375rem;
|
||||||
font-size: 1rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell p {
|
.cell p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.875rem;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A card that has something to admit rather than something to offer.
|
||||||
|
It is not a footnote and it is not fine print: it is the same size as
|
||||||
|
everything else, and it says the same thing the README says. */
|
||||||
|
.cell .caveat {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- install --- */
|
/* --- install --- */
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
@@ -374,6 +510,109 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Four facts under the buttons, in the place where a landing page usually
|
||||||
|
puts logos it has not earned. They are the four questions somebody
|
||||||
|
self-hosting asks before anything else. */
|
||||||
|
ul.facts {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.facts li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The separator belongs between two facts, not after the last one — so it
|
||||||
|
is drawn by the item that follows it. */
|
||||||
|
ul.facts li + li::before {
|
||||||
|
content: '';
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the row wraps, and a separator drawn by the item that follows
|
||||||
|
it lands at the *start* of the new line — a dot with nothing before it.
|
||||||
|
No separators there; the wrap is the separation. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
ul.facts {
|
||||||
|
gap: 0.375rem 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.facts li + li::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two ways to run it, side by side, because they are alternatives rather
|
||||||
|
than steps. Stacked, the second read as what you do after the first. */
|
||||||
|
.panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sunk rather than raised: inside a card, a block in --surface would be
|
||||||
|
the same colour as the card it sits in. */
|
||||||
|
.panel pre {
|
||||||
|
background: var(--ground);
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The non-goals were one paragraph of seven sentences, which is the shape
|
||||||
|
nobody reads. Each is its own line now, and they can be counted. */
|
||||||
|
ul.nogo {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: 0.5rem 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.nogo li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.nogo li::before {
|
||||||
|
content: '—';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
.demo-note {
|
.demo-note {
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
@@ -524,27 +763,38 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
<div class="topbar">
|
||||||
<span aria-hidden="true">◐</span>
|
<div class="wrap bar">
|
||||||
</button>
|
<a class="bar-brand" href="#top">
|
||||||
<div class="wrap">
|
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||||
<header>
|
verdande
|
||||||
|
</a>
|
||||||
|
<nav class="bar-links" aria-label="Sections">
|
||||||
|
<a class="inpage" href="#screens">Screens</a>
|
||||||
|
<a class="inpage" href="#features">Features</a>
|
||||||
|
<a class="inpage" href="#running">Running it</a>
|
||||||
|
<a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
|
<a href="https://github.com/kristianwind/verdande">Source</a>
|
||||||
|
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
||||||
|
<span aria-hidden="true">◐</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap" id="top">
|
||||||
|
<header class="hero">
|
||||||
<span class="mark" aria-hidden="true">ᚹ</span>
|
<span class="mark" aria-hidden="true">ᚹ</span>
|
||||||
<h1>verdande</h1>
|
<h1>verdande</h1>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
Self-hosted tasks and projects, shared with the people you share the work with.
|
Self-hosted tasks, notes and projects, shared with the people you share the work
|
||||||
One binary, SQLite inside it.
|
with. One binary, SQLite inside it.
|
||||||
</p>
|
</p>
|
||||||
<p class="rune-note">
|
<p class="rune-note">
|
||||||
Built to be deployed as a <strong>Rune</strong> in
|
Built to be deployed as a <strong>Rune</strong> in
|
||||||
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
|
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
|
||||||
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
|
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
|
||||||
</p>
|
</p>
|
||||||
<p class="norn">
|
|
||||||
Verdande is one of the three Norns — the one who spins <em>that which is
|
|
||||||
becoming</em>. Not what has been, and not what is fated to be: the thing you are
|
|
||||||
in the middle of. Which is what a to-do list is.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||||
@@ -553,6 +803,14 @@
|
|||||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul class="facts">
|
||||||
|
<li>One Go binary</li>
|
||||||
|
<li>SQLite inside it</li>
|
||||||
|
<li>amd64 and arm64</li>
|
||||||
|
<li>No account anywhere but yours</li>
|
||||||
|
<li>MIT</li>
|
||||||
|
</ul>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
||||||
@@ -584,7 +842,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section id="screens">
|
||||||
<h2>What it looks like</h2>
|
<h2>What it looks like</h2>
|
||||||
<figure class="shot only-dark">
|
<figure class="shot only-dark">
|
||||||
<img
|
<img
|
||||||
@@ -669,11 +927,43 @@
|
|||||||
/>
|
/>
|
||||||
<figcaption>What has been done on the server, across every project.</figcaption>
|
<figcaption>What has been done on the server, across every project.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/delegated.webp"
|
||||||
|
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Waiting on others — grouped by who has it.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/notes-source.webp"
|
||||||
|
alt="A note with the source view open: the same note as Markdown, with headings, a list, a quotation, a wiki link and a fenced code block."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>The same note as Markdown. What is on disk is what you see.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/settings.webp"
|
||||||
|
alt="The integrations settings page: connecting Gmail with an OAuth client, and below it the calendar feed address."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
||||||
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="features">
|
||||||
<h2>What it does</h2>
|
<h2>What it does</h2>
|
||||||
|
|
||||||
|
<p class="group-label">Doing the work</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Reads what you type</h3>
|
<h3>Reads what you type</h3>
|
||||||
@@ -683,20 +973,54 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Shared, properly</h3>
|
<h3>Nothing to remember</h3>
|
||||||
<p>
|
<p>
|
||||||
Owner, editor and viewer roles. People outside your instance join by
|
Start typing anywhere and you are writing a task, with the letter you
|
||||||
invite link. Changes appear live.
|
pressed already in the field. <code>⌘K</code> goes anywhere else.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Search that reads Danish</h3>
|
||||||
|
<p>
|
||||||
|
Type <code>gron</code> and find <code>grøn</code>. Unicode does not
|
||||||
|
consider <code>ø</code> an accented <code>o</code>, so a search that
|
||||||
|
leans on that alone is broken for the people this was built for.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Repeats, and says so in words</h3>
|
||||||
|
<p>
|
||||||
|
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
||||||
|
screen. Reminders are absolute or relative, and go out to the devices
|
||||||
|
you have allowed them on.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Notes, beside the work</h3>
|
<h3>Notes, beside the work</h3>
|
||||||
<p>
|
<p>
|
||||||
Rich text to write in, Markdown on disk. File a note in a project and
|
Rich text to write in, Markdown on disk — and a source view to see it.
|
||||||
everybody in it can read it; <code>#project</code> and
|
Images are pasted or dragged in. <code>#project</code> and
|
||||||
<code>[[another note]]</code> are links, and a task shows what has been
|
<code>[[another note]]</code> are links, and a task shows what has been
|
||||||
written about it. A zip of Markdown goes in and comes back out the same
|
written about it. Archive what is done with it; the bin is for mistakes.
|
||||||
shape, so nothing is trapped here.
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Waiting on others</h3>
|
||||||
|
<p>
|
||||||
|
What you have handed to somebody else, on its own page, grouped under
|
||||||
|
the person who has it. The thing a shared list stops being able to tell
|
||||||
|
you the moment it is shared.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="group-label">Connected to what you already use</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Shared, properly</h3>
|
||||||
|
<p>
|
||||||
|
Owner, editor and viewer roles. People outside your instance join by
|
||||||
|
invite link. Changes appear live.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -706,6 +1030,27 @@
|
|||||||
here. Not a read-only feed pretending.
|
here. Not a read-only feed pretending.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>A calendar of its own</h3>
|
||||||
|
<p>
|
||||||
|
A month or a week of what is due, and a secret ICS address to subscribe
|
||||||
|
to from Apple Calendar, Google or Thunderbird. A Google calendar can be
|
||||||
|
laid over the top, read-only.
|
||||||
|
</p>
|
||||||
|
<p class="caveat">
|
||||||
|
The Google half is written to Google's documentation and has not yet
|
||||||
|
been run against Google. The feed and CalDAV have.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Your mail, as an inbox</h3>
|
||||||
|
<p>
|
||||||
|
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
||||||
|
it becomes a task. Forward one to your own address instead and the
|
||||||
|
subject is parsed like anything else you type. One way: unstarring it
|
||||||
|
later does nothing to the task you made.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Claude, connected</h3>
|
<h3>Claude, connected</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -714,35 +1059,39 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Your mail, as an inbox</h3>
|
<h3>AI only if you ask for it</h3>
|
||||||
<p>
|
<p>
|
||||||
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
|
Off by default, your own key, and your choice of Anthropic, OpenAI,
|
||||||
a task. Forward one to your own address instead and the subject is
|
Google or something local. Nothing leaves the server until you have
|
||||||
parsed like anything else you type. One way: unstarring it later does
|
filled that in, and everything works without it.
|
||||||
nothing to the task you made.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="group-label">Yours, and checkable</p>
|
||||||
|
<div class="grid">
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Nothing to remember</h3>
|
<h3>More than one way in</h3>
|
||||||
<p>
|
<p>
|
||||||
Start typing anywhere and you are writing a task, with the letter you
|
Passkeys, or a password hashed with Argon2id and a second factor with
|
||||||
pressed already in the field. Sign in with a passkey and there is no
|
recovery codes. Every device you are signed in on is listed, and any of
|
||||||
password to forget either.
|
them can be signed out from here.
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="cell">
|
|
||||||
<h3>Yours to leave</h3>
|
|
||||||
<p>
|
|
||||||
Import from Todoist, export back to it, or take everything as JSON. The
|
|
||||||
round trip is tested, not promised.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Keys stay behind</h3>
|
<h3>Keys stay behind</h3>
|
||||||
<p>
|
<p>
|
||||||
Mail tokens and mailbox passwords are encrypted in the database, and the
|
Mail tokens, mailbox passwords, second-factor secrets and AI keys are
|
||||||
key lives in a file beside it — not inside it. So a backup you download,
|
encrypted in the database, and the key lives in a file beside it — not
|
||||||
or one that ends up somewhere it should not, does not open your mail.
|
inside it. So a backup you download, or one that ends up somewhere it
|
||||||
|
should not, does not open your mail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Nobody signs themselves up</h3>
|
||||||
|
<p>
|
||||||
|
The first account is the administrator, and after that people arrive by
|
||||||
|
invitation. An address on the internet is not an open door.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -753,50 +1102,85 @@
|
|||||||
instance's record, not the person's.
|
instance's record, not the person's.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Yours to leave</h3>
|
||||||
|
<p>
|
||||||
|
Import from Todoist, export back to it, take everything as JSON, or
|
||||||
|
hand the notes a zip of Markdown and get the same zip back out. The
|
||||||
|
round trip is tested, not promised.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>It counts itself, out loud</h3>
|
||||||
|
<p>
|
||||||
|
Once a day an instance says it exists — a random id, the version, and
|
||||||
|
nothing else. No tasks, no names, no addresses. It is on to begin with,
|
||||||
|
the settings page prints the exact payload, and one switch stops it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="running">
|
||||||
<h2>Running it</h2>
|
<h2>Running it</h2>
|
||||||
|
|
||||||
<h3 class="sub">As a Rune in Yggdrasil Panel</h3>
|
<div class="panels">
|
||||||
<p class="muted">
|
<div class="panel">
|
||||||
This is what it was built for. <strong>Runes → Carve a rune</strong>, upload
|
<h3>As a Rune in Yggdrasil Panel</h3>
|
||||||
<code>rune/verdande.yaml</code>, create a server from it, set the public address
|
<p>
|
||||||
and your mail server, and start it. No port forwarding — give it a subdomain and
|
This is what it was built for. <strong>Runes → Carve a rune</strong>,
|
||||||
the panel routes it through your Cloudflare Tunnel.
|
upload <code>rune/verdande.yaml</code>, create a server from it, set the
|
||||||
</p>
|
public address and your mail server, and start it. No port forwarding —
|
||||||
|
give it a subdomain and the panel routes it through your Cloudflare
|
||||||
<h3 class="sub">Or with Docker, anywhere</h3>
|
Tunnel.
|
||||||
<pre><code>docker run -d --name verdande \
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Or with Docker, anywhere</h3>
|
||||||
|
<p>Open the address afterwards and create the first account. That one is the administrator.</p>
|
||||||
|
<pre><code>docker run -d --name verdande \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-v verdande-data:/data \
|
-v verdande-data:/data \
|
||||||
-e VERDANDE_BASE_URL=https://todo.example.dk \
|
-e VERDANDE_BASE_URL=https://todo.example.dk \
|
||||||
ghcr.io/kristianwind/verdande:latest</code></pre>
|
ghcr.io/kristianwind/verdande:latest</code></pre>
|
||||||
<p class="muted" style="margin-top: 1rem">
|
</div>
|
||||||
Open the address and create the first account. That one is the administrator.
|
</div>
|
||||||
</p>
|
|
||||||
<ul class="plain">
|
<ul class="plain">
|
||||||
<li>No database to provision — SQLite is inside the binary.</li>
|
<li>No database to provision — SQLite is inside the binary.</li>
|
||||||
<li>Distroless image: no shell, no package manager, one static file.</li>
|
<li>Distroless image: no shell, no package manager, one static file.</li>
|
||||||
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
||||||
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
||||||
|
<li>Twenty-four migrations, run on start. Upgrading is pulling the new image.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="limits">
|
||||||
<h2>What it will not do</h2>
|
<h2>What it will not do</h2>
|
||||||
<p class="muted">
|
<ul class="nogo">
|
||||||
No karma or points. No team workspaces. No native apps. No mail integration
|
<li>No karma, streaks or points.</li>
|
||||||
beyond Gmail and the forwarding address. No languages beyond Danish and English.
|
<li>No team workspaces — projects and the people in them are the whole model.</li>
|
||||||
And no sync protocol of its own — CalDAV and the API <em>are</em> the sync.
|
<li>No native apps. It is a PWA, and it installs on a phone.</li>
|
||||||
</p>
|
<li>No Outlook or Exchange: Microsoft has closed IMAP to passwords and wants its own flow.</li>
|
||||||
|
<li>No languages beyond Danish and English.</li>
|
||||||
|
<li>No sync protocol of its own — CalDAV and the API <em>are</em> the sync.</li>
|
||||||
|
<li>No hosted version. There is nowhere to sign up, and that is the point.</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
|
||||||
|
where it stood between somebody arriving and the buttons they came
|
||||||
|
for — and it is a closing thought, not an opening one. -->
|
||||||
|
<p class="norn">
|
||||||
|
Verdande is one of the three Norns — the one who spins <em>that which is
|
||||||
|
becoming</em>. Not what has been, and not what is fated to be: the thing you are
|
||||||
|
in the middle of. Which is what a to-do list is.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
MIT licensed ·
|
MIT licensed ·
|
||||||
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
||||||
|
· <a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Built to run as a Rune in
|
Built to run as a Rune in
|
||||||
|
|||||||
@@ -358,6 +358,20 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
'aria-expanded',
|
'aria-expanded',
|
||||||
'false'
|
'false'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
|
||||||
|
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
|
||||||
|
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
|
||||||
|
// ingenting at male henover den med.
|
||||||
|
const chevron = await sidebar
|
||||||
|
.locator('.folder-head .chevron-button')
|
||||||
|
.evaluate((el) => ({
|
||||||
|
background: getComputedStyle(el).backgroundColor,
|
||||||
|
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
|
||||||
|
}));
|
||||||
|
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||||
|
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('0');
|
||||||
|
|
||||||
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
||||||
|
|
||||||
// Colour, which is chosen while renaming rather than from the row. Both are
|
// Colour, which is chosen while renaming rather than from the row. Both are
|
||||||
@@ -1814,6 +1828,14 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
|||||||
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||||
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// Og rulleboksen indeni, som er den, der faktisk ruller nu. Bjælken selv er
|
||||||
|
// `overflow: hidden`, så en for bred række ville blive klippet dér og aldrig
|
||||||
|
// nå at tælle med ovenfor — målingen ville stå og bestå af den forkerte grund.
|
||||||
|
const inner = await sidebar
|
||||||
|
.locator('.scroller')
|
||||||
|
.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||||
|
expect(inner, 'rulleboksen kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
// And the whole page, for the same reason at a different scale.
|
// And the whole page, for the same reason at a different scale.
|
||||||
const page_sideways = await page.evaluate(
|
const page_sideways = await page.evaluate(
|
||||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||||
@@ -1827,6 +1849,58 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mærket og de faste visninger bliver stående, når resten ruller.
|
||||||
|
*
|
||||||
|
* Rullebjælken gik før hele vejen op: runen og I dag forsvandt op over kanten, så
|
||||||
|
* snart der var projekter nok — og de er dem, man er på vej hen til oftest.
|
||||||
|
*
|
||||||
|
* Målt i et lavt vindue, for i et højt er der ikke noget at rulle, og en prøve,
|
||||||
|
* der ikke ruller, består uanset hvad man laver om. Bredden holdes over
|
||||||
|
* telefongrænsen, hvor bjælken bliver til en skuffe og er en anden ting.
|
||||||
|
*/
|
||||||
|
test('sidebjælkens hoved bliver stående, når listen ruller', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1000, height: 420 });
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
const scroller = sidebar.locator('.scroller');
|
||||||
|
await expect(scroller).toBeVisible();
|
||||||
|
|
||||||
|
// Rullebjælken begynder under de faste visninger og ikke bag dem.
|
||||||
|
const geometry = await sidebar.evaluate((el) => {
|
||||||
|
const box = (sel) => el.querySelector(sel).getBoundingClientRect().toJSON();
|
||||||
|
const scroll = el.querySelector('.scroller');
|
||||||
|
return {
|
||||||
|
views: box('.views'),
|
||||||
|
scroller: box('.scroller'),
|
||||||
|
overflows: scroll.scrollHeight - scroll.clientHeight
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.overflows, 'der er ikke noget at rulle — prøven beviser intet').toBeGreaterThan(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
geometry.scroller.top,
|
||||||
|
'rulleboksen begynder oppe i de faste visninger'
|
||||||
|
).toBeGreaterThanOrEqual(geometry.views.bottom - 1);
|
||||||
|
|
||||||
|
// Og hovedet bliver, hvor det er, hele vejen ned.
|
||||||
|
const brand = sidebar.locator('.brand');
|
||||||
|
const before = await brand.boundingBox();
|
||||||
|
await scroller.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||||
|
await expect
|
||||||
|
.poll(async () => scroller.evaluate((el) => el.scrollTop))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
const after = await brand.boundingBox();
|
||||||
|
expect(Math.abs(after.y - before.y), 'mærket fulgte med op').toBeLessThanOrEqual(1);
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeInViewport();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A project can have more than one section.
|
* A project can have more than one section.
|
||||||
*
|
*
|
||||||
@@ -1899,6 +1973,76 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sektioner kan trækkes op og ned.
|
||||||
|
*
|
||||||
|
* Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi
|
||||||
|
* det er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette
|
||||||
|
* en sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
|
||||||
|
* område på vejen.
|
||||||
|
*
|
||||||
|
* Målt på rækkefølgen af overskrifterne på siden og bagefter på, hvad serveren
|
||||||
|
* svarer efter en genindlæsning: den første halvdel kan bestå af, at fladen
|
||||||
|
* flyttede et element, den anden kan kun bestå, hvis skrivningen gik igennem.
|
||||||
|
*/
|
||||||
|
test('sektioner kan trækkes i en anden rækkefølge, og den overlever en genindlæsning', async ({
|
||||||
|
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('Havehus');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Havehus' })).toBeVisible();
|
||||||
|
|
||||||
|
for (const name of ['Fundament', 'Rejsning', 'Tag']) {
|
||||||
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||||
|
const field = page.getByLabel('Ny sektion');
|
||||||
|
await field.fill(name);
|
||||||
|
await field.press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = () =>
|
||||||
|
page.locator('.section-head h2').evaluateAll((els) => els.map((e) => e.textContent.trim()));
|
||||||
|
|
||||||
|
expect(await order()).toEqual(['Fundament', 'Rejsning', 'Tag']);
|
||||||
|
|
||||||
|
// Tag trækkes helt op. Sluppet i den øverste halvdel af "Fundament", som er
|
||||||
|
// gaben over den — hvilken halvdel man rammer, er hele forskellen på over og
|
||||||
|
// under, så positionen er skrevet ud og ikke overladt til midten.
|
||||||
|
await page
|
||||||
|
.locator('.section-head')
|
||||||
|
.filter({ hasText: 'Tag' })
|
||||||
|
.dragTo(page.locator('.section-head').filter({ hasText: 'Fundament' }), {
|
||||||
|
targetPosition: { x: 40, y: 3 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(order).toEqual(['Tag', 'Fundament', 'Rejsning']);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tag', exact: true })).toBeVisible();
|
||||||
|
expect(await order(), 'serveren tog ikke rækkefølgen').toEqual([
|
||||||
|
'Tag',
|
||||||
|
'Fundament',
|
||||||
|
'Rejsning'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Og den anden vej: sluppet i den nederste halvdel lander den under.
|
||||||
|
await page
|
||||||
|
.locator('.section-head')
|
||||||
|
.filter({ hasText: 'Tag' })
|
||||||
|
.dragTo(page.locator('.section-head').filter({ hasText: 'Rejsning' }), {
|
||||||
|
targetPosition: { x: 40, y: 28 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(order).toEqual(['Fundament', 'Rejsning', 'Tag']);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form fields on a touch device are at least 16px.
|
* Form fields on a touch device are at least 16px.
|
||||||
*
|
*
|
||||||
@@ -2894,3 +3038,57 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Én note, der ikke kan tegnes, må ikke tage ruden med sig.
|
||||||
|
*
|
||||||
|
* Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker
|
||||||
|
* på, åbner de ikke". Editoren indlæste med `note.body.trim()` uden værn, og
|
||||||
|
* `body` er en streng fra serveren hver eneste gang — indtil den ikke er. Så
|
||||||
|
* kaster den inde i et `$effect`, og en effekt, der kaster, tager komponentens
|
||||||
|
* opdateringer med sig: den forrige note bliver stående, og ingen note kan åbnes
|
||||||
|
* bagefter. Klikket kan ikke engang lande.
|
||||||
|
*
|
||||||
|
* Formen fremtvinges her frem for at vente på den nyttelast, der gjorde det:
|
||||||
|
* hvad der end fik `body` til at mangle, er svaret det samme — én note må ikke
|
||||||
|
* kunne låse de tolv hundrede andre ude.
|
||||||
|
*/
|
||||||
|
test('en note uden krop må ikke låse ruden', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/noter');
|
||||||
|
for (const title of ['Alfa', 'Beta']) {
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type(title);
|
||||||
|
await expect(ed.locator('h1')).toHaveText(title);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Brødtekst i ' + title);
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'Brødtekst i ' + title })).toBeVisible();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
}
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('li .row strong').filter({ hasText: 'Beta' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.route('**/api/v1/notes/*', async (route) => {
|
||||||
|
if (route.request().method() !== 'GET') return route.continue();
|
||||||
|
const res = await route.fetch();
|
||||||
|
const note = await res.json();
|
||||||
|
delete note.body;
|
||||||
|
await route.fulfill({ response: res, json: note });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('li').filter({ hasText: 'Alfa' }).first().click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
expect(errors, 'noten kastede under indlæsningen').toEqual([]);
|
||||||
|
|
||||||
|
await page.unroute('**/api/v1/notes/*');
|
||||||
|
await page.locator('li').filter({ hasText: 'Beta' }).first().click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('textbox', { name: 'Notens tekst' }).locator('h1'),
|
||||||
|
'ruden er låst: den næste note åbner ikke'
|
||||||
|
).toHaveText('Beta', { timeout: 5000 });
|
||||||
|
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -283,6 +283,10 @@ export const api = {
|
|||||||
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
|
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
|
||||||
updateSection: (id, data) => patch(`/sections/${id}`, data),
|
updateSection: (id, data) => patch(`/sections/${id}`, data),
|
||||||
deleteSection: (id) => del(`/sections/${id}`),
|
deleteSection: (id) => del(`/sections/${id}`),
|
||||||
|
// The whole list, the same as projects and groups: a project has a handful of
|
||||||
|
// sections, and an order that cannot land half-applied is worth more than the
|
||||||
|
// bytes a single move would save.
|
||||||
|
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
||||||
|
|
||||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
||||||
|
|||||||
@@ -35,20 +35,53 @@
|
|||||||
let loadedId = $state(null);
|
let loadedId = $state(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!editor || !note || note.id === loadedId) return;
|
if (!editor || !note || note.id === loadedId) return;
|
||||||
// An empty note opens on its title. That is what Apple Notes does, and it is
|
|
||||||
// also what the list needs: the first line becomes the name of the note, so a
|
// Sat *før* tegningen, ikke efter.
|
||||||
// note whose first line is body text is a note called by its first sentence.
|
|
||||||
//
|
//
|
||||||
// Tested on the text and not on the rendered result: an empty body renders as
|
// Kaster noget herunder, er noten stadig den, der er forsøgt indlæst — og
|
||||||
// one empty paragraph, not as nothing, so a falsy check here never fired.
|
// uden det her ville effekten prøve igen ved hver eneste ændring bagefter og
|
||||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
// kaste hver gang.
|
||||||
loadedId = note.id;
|
loadedId = note.id;
|
||||||
active = {};
|
active = {};
|
||||||
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
|
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
|
||||||
// er det den rige flade, man skal møde — ellers står den næste note som kode,
|
// er det den rige flade, man skal møde — ellers står den næste note som kode,
|
||||||
// uden at nogen har bedt om det.
|
// uden at nogen har bedt om det.
|
||||||
asSource = false;
|
asSource = false;
|
||||||
colourCode();
|
|
||||||
|
// Én note, der ikke kan tegnes, må ikke tage ruden med sig.
|
||||||
|
//
|
||||||
|
// Det var `note.body.trim()` uden værn, og `body` er en streng fra serveren
|
||||||
|
// hver eneste gang — indtil den ikke er. Så kaster den her inde i et
|
||||||
|
// `$effect`, og en effekt, der kaster, tager komponentens opdateringer med
|
||||||
|
// sig: den forrige note bliver stående, og *ingen* note kan åbnes bagefter.
|
||||||
|
// Klikket kan ikke engang lande. Meldt ind som "jeg kan ikke skifte mellem
|
||||||
|
// noter, uanset hvilken jeg trykker på", hvilket er præcis, hvad det ser ud
|
||||||
|
// som fra den anden side.
|
||||||
|
//
|
||||||
|
// Fejlen står i konsollen frem for i en toast: den hører til den, der kan
|
||||||
|
// rette den, og en note, der viser sin egen tekst uden formatering, siger
|
||||||
|
// allerede til den, der læser den, at noget er galt.
|
||||||
|
//
|
||||||
|
// `textContent` og ikke `innerHTML` i faldbakken — teksten er ikke tegnet, og
|
||||||
|
// at lægge den ind som markup ville være at køre det, tegningen lige nægtede.
|
||||||
|
// Gemningen er ikke i fare: den skriver `draft`, som kommer fra svaret og
|
||||||
|
// ikke fra det, der står i editoren, indtil nogen taster i den.
|
||||||
|
const body = note.body ?? '';
|
||||||
|
try {
|
||||||
|
// An empty note opens on its title. That is what Apple Notes does, and it
|
||||||
|
// is also what the list needs: the first line becomes the name of the
|
||||||
|
// note, so a note whose first line is body text is a note called by its
|
||||||
|
// first sentence.
|
||||||
|
//
|
||||||
|
// Tested on the text and not on the rendered result: an empty body renders
|
||||||
|
// as one empty paragraph, not as nothing, so a falsy check here never
|
||||||
|
// fired.
|
||||||
|
editor.innerHTML = body.trim() ? markdownToHtml(body) : '<h1><br></h1>';
|
||||||
|
colourCode();
|
||||||
|
} catch (e) {
|
||||||
|
editor.textContent = body;
|
||||||
|
console.error('verdande: noten kunne ikke tegnes', note.id, e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const SYNTAX = [
|
const SYNTAX = [
|
||||||
@@ -884,8 +917,20 @@
|
|||||||
* clamp frem for et tal: en smal rude får det hele, en bred får en
|
* clamp frem for et tal: en smal rude får det hele, en bred får en
|
||||||
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
|
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
|
||||||
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
|
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
|
||||||
max-width: clamp(38rem, 92%, 68rem);
|
/* Grænsen sættes som polstring og ikke som bredde, og det er rullebjælken,
|
||||||
|
* der afgør det.
|
||||||
|
*
|
||||||
|
* Det er *dette* element, der ruller. Var det også det, der blev smallere,
|
||||||
|
* fulgte rullebjælken med ind: den stod ved tekstsøjlens kant med en
|
||||||
|
* håndflade tom rude til højre for sig og svævede midt i arket. Med
|
||||||
|
* polstring er elementet lige så bredt som ruden — bjælken bliver ved
|
||||||
|
* kanten, hvor den hører til — og teksten er nøjagtig lige så bred som før.
|
||||||
|
*
|
||||||
|
* `max()` mod nul, fordi en smal rude ellers ville få negativ polstring, og
|
||||||
|
* `border-box` er sat globalt, så bredden er hele boksen og ikke boksen plus
|
||||||
|
* polstringen. Uden det ville det her give en vandret rullebjælke. */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding-right: max(0px, calc(100% - clamp(38rem, 92%, 68rem)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
|
|||||||
@@ -639,301 +639,313 @@
|
|||||||
</h2>
|
</h2>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="group">
|
<!-- Alt herunder ruller; mærket og de faste visninger gør ikke.
|
||||||
<!-- The heading is also the drop target for "no group": without it, a project
|
Hele sidebjælken rullede før, så runen og I dag forsvandt op over kanten,
|
||||||
dragged into a group could only be got out again by emptying the group,
|
så snart der var projekter nok — og de er dem, man er på vej hen til
|
||||||
and with every project filed there would be no loose row left to aim at. -->
|
oftest. En rulleboks indeni frem for `position: sticky` udenom, fordi et
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
klæbende hoved efterlader rullebjælken i fuld højde ved siden af sig: den
|
||||||
<div
|
skal begynde under hovedet, ikke bag det.
|
||||||
class="group-head"
|
|
||||||
class:over={overGroup === ''}
|
|
||||||
ondragover={(e) => {
|
|
||||||
if (!carries(e, PROJECT)) return;
|
|
||||||
accept(e);
|
|
||||||
overGroup = '';
|
|
||||||
overId = null;
|
|
||||||
}}
|
|
||||||
ondragleave={() => (overGroup = null)}
|
|
||||||
ondrop={(e) => onDropInGroup(e, '')}
|
|
||||||
>
|
|
||||||
{@render foldHeading('projects', t('sidebar.projects'))}
|
|
||||||
<button
|
|
||||||
class="icon"
|
|
||||||
onclick={() => {
|
|
||||||
adding = !adding;
|
|
||||||
addingGroup = false;
|
|
||||||
}}
|
|
||||||
aria-label={t('sidebar.newProject')}>+</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="icon new-group"
|
|
||||||
onclick={() => {
|
|
||||||
addingGroup = !addingGroup;
|
|
||||||
adding = false;
|
|
||||||
newGroupName = '';
|
|
||||||
}}
|
|
||||||
aria-label={t('sidebar.newGroup')}
|
|
||||||
title={t('sidebar.newGroup')}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if adding}
|
Snippet-erklæringerne bliver liggende udenfor. De tegner ikke noget selv,
|
||||||
<form onsubmit={createProject}>
|
men de kaldes oppe fra .views, og en snippet erklæret herinde hører kun
|
||||||
<input
|
til herinde. -->
|
||||||
bind:value={newName}
|
<div class="scroller">
|
||||||
use:focusOnMount
|
<div class="group">
|
||||||
placeholder={t('sidebar.projectName')}
|
<!-- The heading is also the drop target for "no group": without it, a project
|
||||||
aria-label={t('sidebar.projectName')}
|
dragged into a group could only be got out again by emptying the group,
|
||||||
onblur={() => !newName.trim() && (adding = false)}
|
and with every project filed there would be no loose row left to aim at. -->
|
||||||
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if addingGroup}
|
|
||||||
<form onsubmit={createGroup}>
|
|
||||||
<input
|
|
||||||
bind:value={newGroupName}
|
|
||||||
use:focusOnMount
|
|
||||||
placeholder={t('sidebar.groupName')}
|
|
||||||
aria-label={t('sidebar.groupName')}
|
|
||||||
onblur={() => !newGroupName.trim() && (addingGroup = false)}
|
|
||||||
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !app.sectionCollapsed('projects')}
|
|
||||||
{#each ungrouped as project (project.id)}
|
|
||||||
{@render projectRow(project, true)}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if own.length === 0 && !adding}
|
|
||||||
<p class="empty">{t('sidebar.noProjects')}</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each groups as group (group.id)}
|
|
||||||
{@const inside = inGroup(group.id)}
|
|
||||||
<div class="group folder">
|
|
||||||
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
|
|
||||||
the pointer from selecting text in the field it contains. -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="group-head folder-head"
|
class="group-head"
|
||||||
class:over={overGroup === group.id}
|
class:over={overGroup === ''}
|
||||||
class:drop-above={overId === group.id && !overBelow}
|
ondragover={(e) => {
|
||||||
class:drop-below={overId === group.id && overBelow}
|
if (!carries(e, PROJECT)) return;
|
||||||
class:dragging={draggingGroup === group.id}
|
accept(e);
|
||||||
draggable={renamingGroup !== group.id}
|
overGroup = '';
|
||||||
ondragstart={(e) => onGroupDragStart(e, group)}
|
|
||||||
ondragend={clearDrag}
|
|
||||||
ondragover={(e) => onGroupDragOver(e, group)}
|
|
||||||
ondragleave={() => {
|
|
||||||
overGroup = null;
|
|
||||||
overId = null;
|
overId = null;
|
||||||
}}
|
}}
|
||||||
ondrop={(e) => onGroupDrop(e, group)}
|
ondragleave={() => (overGroup = null)}
|
||||||
ondblclick={() => startRename(group)}
|
ondrop={(e) => onDropInGroup(e, '')}
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === 'F2') {
|
|
||||||
e.preventDefault();
|
|
||||||
startRename(group);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{#if renamingGroup === group.id}
|
{@render foldHeading('projects', t('sidebar.projects'))}
|
||||||
<!-- Renaming is where the colour is chosen too.
|
<button
|
||||||
The dot used to be a button in the row, which made the heading
|
class="icon"
|
||||||
carry four controls — dot, name, Omdøb, Slet — on a line narrow
|
onclick={() => {
|
||||||
enough that they crowded the name. Both are edits to the same
|
adding = !adding;
|
||||||
thing, so both belong in the same moment: you open the group to
|
addingGroup = false;
|
||||||
change it, and change what you came to change.
|
}}
|
||||||
|
aria-label={t('sidebar.newProject')}>+</button
|
||||||
`focusout` rather than `blur` closes it, and only when focus has
|
>
|
||||||
left the form altogether — clicking a swatch is still inside it.
|
<button
|
||||||
The swatches also refuse focus on mousedown, because Safari and
|
class="icon new-group"
|
||||||
Firefox do not focus a button on click, so `relatedTarget` would
|
onclick={() => {
|
||||||
be null and the form would shut under the pointer. -->
|
addingGroup = !addingGroup;
|
||||||
<form
|
adding = false;
|
||||||
class="renaming"
|
newGroupName = '';
|
||||||
onsubmit={(e) => renameGroup(e, group)}
|
}}
|
||||||
onfocusout={(e) => {
|
aria-label={t('sidebar.newGroup')}
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
|
title={t('sidebar.newGroup')}
|
||||||
}}
|
>
|
||||||
>
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<input
|
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||||
bind:value={groupName}
|
</svg>
|
||||||
use:focusOnMount
|
</button>
|
||||||
aria-label={t('sidebar.groupName')}
|
|
||||||
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
|
|
||||||
/>
|
|
||||||
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
|
|
||||||
{#each COLORS as color (color.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="swatch"
|
|
||||||
class:chosen={(group.color ?? 'graphite') === color.id}
|
|
||||||
style="background: {colorVar(color.id)}"
|
|
||||||
title={t(color.name)}
|
|
||||||
aria-label={t(color.name)}
|
|
||||||
aria-pressed={(group.color ?? 'graphite') === color.id}
|
|
||||||
onmousedown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => app.setGroupColor(group.id, color.id)}
|
|
||||||
></button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<!-- The button sits inside the heading, not around it, the same way a
|
|
||||||
project's title does: a group *is* a heading in the sidebar, and
|
|
||||||
wrapping an h2 in a button both invalidates the markup and takes
|
|
||||||
the heading away from a screen reader. The whole heading folds —
|
|
||||||
at this size a lone chevron is a target you miss, and the name is
|
|
||||||
where the eye already is. -->
|
|
||||||
<!-- The chevron folds; the name opens the group's own page. Two
|
|
||||||
targets in one heading, because they are two different intentions
|
|
||||||
and the old single one could only ever serve the smaller. -->
|
|
||||||
<button
|
|
||||||
class="chevron-button"
|
|
||||||
onclick={() => app.toggleGroup(group.id)}
|
|
||||||
aria-expanded={!group.collapsed}
|
|
||||||
aria-label={group.collapsed
|
|
||||||
? t('sidebar.unfold', { name: group.name })
|
|
||||||
: t('sidebar.fold', { name: group.name })}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="chevron"
|
|
||||||
class:collapsed={group.collapsed}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<h2 class="fold">
|
|
||||||
<!-- Navigation waits two tenths of a second for a second click. A
|
|
||||||
link cannot know a double is coming without pausing, and the
|
|
||||||
alternative was worse than the pause: the first click opened the
|
|
||||||
page while the rename form was appearing, and the colour swatches
|
|
||||||
moved out from under the pointer. Two tenths is under the double
|
|
||||||
click threshold and over the threshold of noticing. -->
|
|
||||||
<a
|
|
||||||
href="/gruppe/{group.id}"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
clearTimeout(navTimer);
|
|
||||||
if (e.detail > 1) return;
|
|
||||||
navTimer = setTimeout(() => {
|
|
||||||
onnavigate();
|
|
||||||
goto(`/gruppe/${group.id}`);
|
|
||||||
}, 200);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="dot group-dot"
|
|
||||||
style="background: {colorVar(group.color)}"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
{group.name}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
<span class="count">{inside.length}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !group.collapsed}
|
{#if adding}
|
||||||
{#each inside as project (project.id)}
|
<form onsubmit={createProject}>
|
||||||
|
<input
|
||||||
|
bind:value={newName}
|
||||||
|
use:focusOnMount
|
||||||
|
placeholder={t('sidebar.projectName')}
|
||||||
|
aria-label={t('sidebar.projectName')}
|
||||||
|
onblur={() => !newName.trim() && (adding = false)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if addingGroup}
|
||||||
|
<form onsubmit={createGroup}>
|
||||||
|
<input
|
||||||
|
bind:value={newGroupName}
|
||||||
|
use:focusOnMount
|
||||||
|
placeholder={t('sidebar.groupName')}
|
||||||
|
aria-label={t('sidebar.groupName')}
|
||||||
|
onblur={() => !newGroupName.trim() && (addingGroup = false)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !app.sectionCollapsed('projects')}
|
||||||
|
{#each ungrouped as project (project.id)}
|
||||||
{@render projectRow(project, true)}
|
{@render projectRow(project, true)}
|
||||||
{/each}
|
{/each}
|
||||||
{#if !inside.length}
|
|
||||||
<p class="empty">{t('sidebar.emptyGroup')}</p>
|
{#if own.length === 0 && !adding}
|
||||||
|
<p class="empty">{t('sidebar.noProjects')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if shared.length}
|
{#each groups as group (group.id)}
|
||||||
<div class="group">
|
{@const inside = inGroup(group.id)}
|
||||||
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
|
<div class="group folder">
|
||||||
{#if !app.sectionCollapsed('shared')}
|
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
|
||||||
{#each shared as project (project.id)}
|
the pointer from selecting text in the field it contains. -->
|
||||||
{@render projectRow(project, false)}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
{/each}
|
<div
|
||||||
{/if}
|
class="group-head folder-head"
|
||||||
</div>
|
class:over={overGroup === group.id}
|
||||||
{/if}
|
class:drop-above={overId === group.id && !overBelow}
|
||||||
|
class:drop-below={overId === group.id && overBelow}
|
||||||
|
class:dragging={draggingGroup === group.id}
|
||||||
|
draggable={renamingGroup !== group.id}
|
||||||
|
ondragstart={(e) => onGroupDragStart(e, group)}
|
||||||
|
ondragend={clearDrag}
|
||||||
|
ondragover={(e) => onGroupDragOver(e, group)}
|
||||||
|
ondragleave={() => {
|
||||||
|
overGroup = null;
|
||||||
|
overId = null;
|
||||||
|
}}
|
||||||
|
ondrop={(e) => onGroupDrop(e, group)}
|
||||||
|
ondblclick={() => startRename(group)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
e.preventDefault();
|
||||||
|
startRename(group);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if renamingGroup === group.id}
|
||||||
|
<!-- Renaming is where the colour is chosen too.
|
||||||
|
The dot used to be a button in the row, which made the heading
|
||||||
|
carry four controls — dot, name, Omdøb, Slet — on a line narrow
|
||||||
|
enough that they crowded the name. Both are edits to the same
|
||||||
|
thing, so both belong in the same moment: you open the group to
|
||||||
|
change it, and change what you came to change.
|
||||||
|
|
||||||
{#if filters.length}
|
`focusout` rather than `blur` closes it, and only when focus has
|
||||||
<div class="group">
|
left the form altogether — clicking a swatch is still inside it.
|
||||||
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
|
The swatches also refuse focus on mousedown, because Safari and
|
||||||
{#if !app.sectionCollapsed('filters')}
|
Firefox do not focus a button on click, so `relatedTarget` would
|
||||||
{#each filters as filter (filter.id)}
|
be null and the form would shut under the pointer. -->
|
||||||
<a
|
<form
|
||||||
href="/filter/{filter.id}"
|
class="renaming"
|
||||||
class:active={current === `/filter/${filter.id}`}
|
onsubmit={(e) => renameGroup(e, group)}
|
||||||
onclick={onnavigate}
|
onfocusout={(e) => {
|
||||||
>
|
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
|
||||||
<span class="dot" aria-hidden="true"></span>
|
}}
|
||||||
{filter.name}
|
>
|
||||||
</a>
|
<input
|
||||||
{/each}
|
bind:value={groupName}
|
||||||
{/if}
|
use:focusOnMount
|
||||||
</div>
|
aria-label={t('sidebar.groupName')}
|
||||||
{/if}
|
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
|
||||||
|
/>
|
||||||
|
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
|
||||||
|
{#each COLORS as color (color.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="swatch"
|
||||||
|
class:chosen={(group.color ?? 'graphite') === color.id}
|
||||||
|
style="background: {colorVar(color.id)}"
|
||||||
|
title={t(color.name)}
|
||||||
|
aria-label={t(color.name)}
|
||||||
|
aria-pressed={(group.color ?? 'graphite') === color.id}
|
||||||
|
onmousedown={(e) => e.preventDefault()}
|
||||||
|
onclick={() => app.setGroupColor(group.id, color.id)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- The button sits inside the heading, not around it, the same way a
|
||||||
|
project's title does: a group *is* a heading in the sidebar, and
|
||||||
|
wrapping an h2 in a button both invalidates the markup and takes
|
||||||
|
the heading away from a screen reader. The whole heading folds —
|
||||||
|
at this size a lone chevron is a target you miss, and the name is
|
||||||
|
where the eye already is. -->
|
||||||
|
<!-- The chevron folds; the name opens the group's own page. Two
|
||||||
|
targets in one heading, because they are two different intentions
|
||||||
|
and the old single one could only ever serve the smaller. -->
|
||||||
|
<button
|
||||||
|
class="chevron-button"
|
||||||
|
onclick={() => app.toggleGroup(group.id)}
|
||||||
|
aria-expanded={!group.collapsed}
|
||||||
|
aria-label={group.collapsed
|
||||||
|
? t('sidebar.unfold', { name: group.name })
|
||||||
|
: t('sidebar.fold', { name: group.name })}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="chevron"
|
||||||
|
class:collapsed={group.collapsed}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 class="fold">
|
||||||
|
<!-- Navigation waits two tenths of a second for a second click. A
|
||||||
|
link cannot know a double is coming without pausing, and the
|
||||||
|
alternative was worse than the pause: the first click opened the
|
||||||
|
page while the rename form was appearing, and the colour swatches
|
||||||
|
moved out from under the pointer. Two tenths is under the double
|
||||||
|
click threshold and over the threshold of noticing. -->
|
||||||
|
<a
|
||||||
|
href="/gruppe/{group.id}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(navTimer);
|
||||||
|
if (e.detail > 1) return;
|
||||||
|
navTimer = setTimeout(() => {
|
||||||
|
onnavigate();
|
||||||
|
goto(`/gruppe/${group.id}`);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="dot group-dot"
|
||||||
|
style="background: {colorVar(group.color)}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{group.name}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<span class="count">{inside.length}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if labels.length}
|
{#if !group.collapsed}
|
||||||
<div class="group">
|
{#each inside as project (project.id)}
|
||||||
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
|
{@render projectRow(project, true)}
|
||||||
{#if !app.sectionCollapsed('labels')}
|
{/each}
|
||||||
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
|
{#if !inside.length}
|
||||||
<a
|
<p class="empty">{t('sidebar.emptyGroup')}</p>
|
||||||
href="/etiket/{encodeURIComponent(label.name)}"
|
{/if}
|
||||||
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
|
{/if}
|
||||||
onclick={onnavigate}
|
</div>
|
||||||
>
|
{/each}
|
||||||
<span class="dot" aria-hidden="true"></span>
|
|
||||||
{label.name}
|
|
||||||
<span class="count">{label.task_count}</span>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="foot">
|
{#if shared.length}
|
||||||
<a
|
<div class="group">
|
||||||
href="/indstillinger"
|
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
|
||||||
class="settings"
|
{#if !app.sectionCollapsed('shared')}
|
||||||
class:active={current.startsWith('/indstillinger')}
|
{#each shared as project (project.id)}
|
||||||
onclick={onnavigate}
|
{@render projectRow(project, false)}
|
||||||
>
|
{/each}
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
{/if}
|
||||||
<circle cx="12" cy="12" r="3" />
|
</div>
|
||||||
<path
|
|
||||||
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{t('nav.settings')}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
|
|
||||||
normal, and the app keeps working without it. -->
|
|
||||||
{#if !app.connected}
|
|
||||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<button class="user" onclick={signOut}>
|
|
||||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
{#if filters.length}
|
||||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
<div class="group">
|
||||||
</span>
|
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
|
||||||
<span class="user-name">{app.user?.name}</span>
|
{#if !app.sectionCollapsed('filters')}
|
||||||
<span class="signout">{t('nav.signOut')}</span>
|
{#each filters as filter (filter.id)}
|
||||||
</button>
|
<a
|
||||||
|
href="/filter/{filter.id}"
|
||||||
|
class:active={current === `/filter/${filter.id}`}
|
||||||
|
onclick={onnavigate}
|
||||||
|
>
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
{filter.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if labels.length}
|
||||||
|
<div class="group">
|
||||||
|
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
|
||||||
|
{#if !app.sectionCollapsed('labels')}
|
||||||
|
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
|
||||||
|
<a
|
||||||
|
href="/etiket/{encodeURIComponent(label.name)}"
|
||||||
|
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
|
||||||
|
onclick={onnavigate}
|
||||||
|
>
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
{label.name}
|
||||||
|
<span class="count">{label.task_count}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
<a
|
||||||
|
href="/indstillinger"
|
||||||
|
class="settings"
|
||||||
|
class:active={current.startsWith('/indstillinger')}
|
||||||
|
onclick={onnavigate}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('nav.settings')}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
|
||||||
|
normal, and the app keeps working without it. -->
|
||||||
|
{#if !app.connected}
|
||||||
|
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||||
|
{/if}
|
||||||
|
<button class="user" onclick={signOut}>
|
||||||
|
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||||
|
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||||
|
</span>
|
||||||
|
<span class="user-name">{app.user?.name}</span>
|
||||||
|
<span class="signout">{t('nav.signOut')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||||
A drag handle with no keyboard equivalent is a setting some people simply
|
A drag handle with no keyboard equivalent is a setting some people simply
|
||||||
@@ -970,11 +982,31 @@
|
|||||||
padding: var(--s4) var(--s3);
|
padding: var(--s4) var(--s3);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
overflow-y: auto;
|
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
||||||
|
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
||||||
|
skal den klippes her og ikke skubbe kolonnen bredere. */
|
||||||
|
overflow: hidden;
|
||||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `min-height: 0`, ellers nægter et flex-element at blive lavere end sit
|
||||||
|
indhold, og boksen ville vokse ned gennem bunden af skærmen i stedet for at
|
||||||
|
rulle — samme fælde som `min-width: 0` på kontonavnet længere nede.
|
||||||
|
|
||||||
|
Ikke `overflow-x: hidden` her: `overflow-y: auto` gør den anden akse til
|
||||||
|
`auto` af sig selv, og en vandret rullebjælke under menuen er en fejl i en
|
||||||
|
række, ikke i boksen. At klippe den ville skjule fejlen; prøven måler begge
|
||||||
|
bokse. */
|
||||||
|
.scroller {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
||||||
the border; this only widens what the pointer hits.
|
the border; this only widens what the pointer hits.
|
||||||
|
|
||||||
@@ -1213,6 +1245,11 @@
|
|||||||
The dot is the resting state; the chevron takes over on hover or focus, and
|
The dot is the resting state; the chevron takes over on hover or focus, and
|
||||||
stays put while the group is folded, so a folded group still shows the way
|
stays put while the group is folded, so a folded group still shows the way
|
||||||
back open. */
|
back open. */
|
||||||
|
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
|
||||||
|
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
|
||||||
|
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
|
||||||
|
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
|
||||||
|
skifte, uden noget at male henover den med. */
|
||||||
.chevron-button {
|
.chevron-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: calc(var(--s2) - 5px);
|
left: calc(var(--s2) - 5px);
|
||||||
@@ -1223,7 +1260,13 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
background: var(--surface);
|
/* Fordi prikken herunder tones ud frem for at blive dækket. En `opacity`
|
||||||
|
under 1 laver en stakkontekst og maler elementet, som var det placeret
|
||||||
|
med `z-index: 0` — så prikken, der står senere i DOM'en, lagde sig oven
|
||||||
|
på knappen i det øjeblik den forsvandt. Den var usynlig og tog stadig
|
||||||
|
klikket: prøven brugte tredive sekunder på at folde en gruppe, der ikke
|
||||||
|
ville foldes. Ét lag op, og rækkefølgen er den samme som før. */
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-head:hover .chevron-button,
|
.folder-head:hover .chevron-button,
|
||||||
@@ -1232,9 +1275,18 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Præcis de tre tilfælde ovenfor, læst nedad i stedet for opad: står chevronen
|
||||||
|
der, er prikken væk. `opacity` og ikke `display`, for de to ligger oven i
|
||||||
|
hinanden — forsvandt prikken ud af flowet, ville navnet rykke sig hver gang
|
||||||
|
pilen kom frem. */
|
||||||
|
.folder-head:hover .group-dot,
|
||||||
|
.folder-head:has(.chevron-button:focus-visible) .group-dot,
|
||||||
|
.folder-head:has(.chevron-button[aria-expanded='false']) .group-dot {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chevron-button:hover {
|
.chevron-button:hover {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--surface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fold a,
|
.fold a,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const TASK = 'application/x-verdande-task';
|
|||||||
export const PROJECT = 'application/x-verdande-project';
|
export const PROJECT = 'application/x-verdande-project';
|
||||||
export const GROUP = 'application/x-verdande-group';
|
export const GROUP = 'application/x-verdande-group';
|
||||||
export const NOTE = 'application/x-verdande-note';
|
export const NOTE = 'application/x-verdande-note';
|
||||||
|
export const SECTION = 'application/x-verdande-section';
|
||||||
|
|
||||||
/** Starts a drag carrying one id of the given kind. */
|
/** Starts a drag carrying one id of the given kind. */
|
||||||
export function startDrag(event, type, id) {
|
export function startDrag(event, type, id) {
|
||||||
|
|||||||
@@ -437,7 +437,6 @@
|
|||||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||||
padding-right: var(--s3);
|
padding-right: var(--s3);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { eventName, eventDetail } from '$lib/events.js';
|
import { eventName, eventDetail } from '$lib/events.js';
|
||||||
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
@@ -386,6 +386,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- moving a section ---------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The order of the sections is the order of the plan — "Planlægning" stands
|
||||||
|
// before "I gang" because that is when the work happens — and until this it
|
||||||
|
// could only be changed by deleting a section and making it again, which drops
|
||||||
|
// its tasks into the unsectioned area on the way past.
|
||||||
|
//
|
||||||
|
// The whole heading is the handle, not a grip beside it. A section heading
|
||||||
|
// holds nothing that is itself dragged, so there is no gesture to steal — which
|
||||||
|
// is the reason the sidebar's rows needed one, and the reason these do not.
|
||||||
|
|
||||||
|
let draggingSection = $state(null);
|
||||||
|
/** The heading whose gap is lit up, and which side of it. */
|
||||||
|
let overSectionEdge = $state(null);
|
||||||
|
let overSectionBelow = $state(false);
|
||||||
|
|
||||||
|
function clearSectionDrag() {
|
||||||
|
draggingSection = null;
|
||||||
|
overSectionEdge = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDragStart(event, section) {
|
||||||
|
draggingSection = section.id;
|
||||||
|
startDrag(event, SECTION, section.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHeadDragOver(event, section) {
|
||||||
|
if (!canEdit || !carries(event, SECTION) || draggingSection === section.id) return;
|
||||||
|
accept(event);
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
overSectionEdge = section.id;
|
||||||
|
overSectionBelow = event.clientY > box.top + box.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic, and the whole list at once.
|
||||||
|
*
|
||||||
|
* The array is what the page renders from, so moving the entry moves the
|
||||||
|
* section; `sort_order` is set alongside it only so a later read of this list
|
||||||
|
* agrees with what is on screen. If the write fails the previous array goes
|
||||||
|
* back — a half-moved section is worse than one that did not move.
|
||||||
|
*/
|
||||||
|
async function onHeadDrop(event, target) {
|
||||||
|
if (!carries(event, SECTION)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const id = dragged(event, SECTION) || draggingSection;
|
||||||
|
const below = overSectionBelow;
|
||||||
|
clearSectionDrag();
|
||||||
|
if (!id || id === target.id || !canEdit) return;
|
||||||
|
|
||||||
|
const moved = sections.find((s) => s.id === id);
|
||||||
|
if (!moved) return;
|
||||||
|
|
||||||
|
const without = sections.filter((s) => s.id !== id);
|
||||||
|
const at = without.findIndex((s) => s.id === target.id);
|
||||||
|
if (at < 0) return;
|
||||||
|
|
||||||
|
const ordered = [...without];
|
||||||
|
ordered.splice(below ? at + 1 : at, 0, moved);
|
||||||
|
|
||||||
|
const previous = sections;
|
||||||
|
sections = ordered.map((s, i) => ({ ...s, sort_order: i }));
|
||||||
|
try {
|
||||||
|
await api.reorderSections(
|
||||||
|
project.id,
|
||||||
|
ordered.map((s) => s.id)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
sections = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeSection(section) {
|
async function removeSection(section) {
|
||||||
const count = open.filter((t) => t.section_id === section.id).length;
|
const count = open.filter((t) => t.section_id === section.id).length;
|
||||||
const warning = count
|
const warning = count
|
||||||
@@ -739,7 +813,22 @@
|
|||||||
ondragleave={() => (overSection = null)}
|
ondragleave={() => (overSection = null)}
|
||||||
ondrop={(e) => onSectionDrop(e, section.id)}
|
ondrop={(e) => onSectionDrop(e, section.id)}
|
||||||
>
|
>
|
||||||
<div class="section-head">
|
<!-- Draggable, except while it is being renamed: a draggable element
|
||||||
|
will not let the pointer select text in the field it contains.
|
||||||
|
The sidebar's groups learned this first. -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="section-head"
|
||||||
|
class:dragging={draggingSection === section.id}
|
||||||
|
class:drop-above={overSectionEdge === section.id && !overSectionBelow}
|
||||||
|
class:drop-below={overSectionEdge === section.id && overSectionBelow}
|
||||||
|
draggable={canEdit && renamingSection !== section.id}
|
||||||
|
ondragstart={(e) => onSectionDragStart(e, section)}
|
||||||
|
ondragend={clearSectionDrag}
|
||||||
|
ondragover={(e) => onHeadDragOver(e, section)}
|
||||||
|
ondragleave={() => (overSectionEdge = null)}
|
||||||
|
ondrop={(e) => onHeadDrop(e, section)}
|
||||||
|
>
|
||||||
{#if renamingSection === section.id}
|
{#if renamingSection === section.id}
|
||||||
<form onsubmit={(e) => renameSection(e, section)}>
|
<form onsubmit={(e) => renameSection(e, section)}>
|
||||||
<input
|
<input
|
||||||
@@ -1441,12 +1530,61 @@
|
|||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: var(--s2) var(--s3);
|
|
||||||
margin-bottom: var(--s1);
|
margin-bottom: var(--s1);
|
||||||
/* Pulled out to the padding of the rows below, so the band lines up with the
|
/* The same inset as the rows, and the same width.
|
||||||
text it heads rather than with the rows' own inset. */
|
*
|
||||||
margin-left: calc(var(--s2) * -1);
|
* It used to be pulled out by `--s2` and padded by `--s3`, which is the same
|
||||||
margin-right: calc(var(--s2) * -1);
|
* idea done with two different numbers: the band ended up eight pixels wider
|
||||||
|
* than the list on each side, and its text four pixels *outside* the column
|
||||||
|
* the rows keep. On the left that was invisible. On the right it was Omdøb
|
||||||
|
* and Slet standing four pixels past everything above and below them, which
|
||||||
|
* is exactly far enough to look like a mistake and not far enough to see why.
|
||||||
|
*
|
||||||
|
* A row is `.row` with `padding: var(--s2)` inside a box with none, so the
|
||||||
|
* column the eye follows starts one `--s2` in. The band matches that by
|
||||||
|
* having the same padding and no margin at all — no arithmetic, and nothing
|
||||||
|
* to keep in step if the rows are ever re-inset.
|
||||||
|
*/
|
||||||
|
padding: var(--s2);
|
||||||
|
/* For the drop line, which is drawn inside the band's own box. */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grabbable, and only where it can actually be grabbed: the rename field is
|
||||||
|
not draggable, and a heading somebody may only read is not either. */
|
||||||
|
.section-head[draggable='true'] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A line in the gap rather than a box around the heading. The gap is where it
|
||||||
|
will land, and a lit heading says "into this one" — which is what a task
|
||||||
|
dragged onto a section means, and this is not that. */
|
||||||
|
.section-head::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head.drop-above::after {
|
||||||
|
top: -1px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head.drop-below::after {
|
||||||
|
bottom: -1px;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head h2 {
|
.section-head h2 {
|
||||||
|
|||||||
Reference in New Issue
Block a user