Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247277e139 | ||
|
|
f3b037c7f4 | ||
|
|
4103e1b0ca | ||
|
|
9d4a13c2c2 | ||
|
|
923f911472 | ||
|
|
272c4918b0 | ||
|
|
ff866cc514 | ||
|
|
e94d731c30 | ||
|
|
0aa0ec2989 | ||
|
|
21b60b007a |
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
|
||||
integrations that a hosted product will not build for you: a real CalDAV server, a
|
||||
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
|
||||
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||
lists — on your own hardware, with your own data, and with the integrations that a
|
||||
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
|
||||
and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
## What it does
|
||||
|
||||
|
||||
@@ -80,6 +80,16 @@ func run() error {
|
||||
log.Info("notes sealed", "count", sealed)
|
||||
}
|
||||
|
||||
// Names created before the app capitalised them on the way in — "andreas"
|
||||
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
|
||||
// caught up. A failure here does not stop the start: a lower-case first name is
|
||||
// a blemish, not a broken instance.
|
||||
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
|
||||
log.Error("normalize user names", "err", err)
|
||||
} else if fixed > 0 {
|
||||
log.Info("user names normalized", "count", fixed)
|
||||
}
|
||||
|
||||
httpapi.Version = version
|
||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Saved filters
|
||||
|
||||
A filter is a small expression describing a slice of your tasks. The grammar is
|
||||
Todoist's, so if you are coming from there you already know it.
|
||||
A filter is a small expression describing a slice of your tasks. The grammar will
|
||||
feel familiar if you have used query-based filters in other task apps.
|
||||
|
||||
```
|
||||
today & p1
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ of. Which is what a to-do list is.
|
||||
|
||||
## What it is for
|
||||
|
||||
Todoist Pro, on your own hardware, with your own data. And with the integrations a
|
||||
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||
lists — on your own hardware, with your own data. And with the integrations a
|
||||
hosted product will not build for you: a real CalDAV server, an address you can
|
||||
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Self-hosting
|
||||
# With Docker
|
||||
|
||||
verdande is one static binary and one directory. There is no database server, no
|
||||
cache, no queue and no second process.
|
||||
Running it yourself outside a panel: one static binary and one directory. There is
|
||||
no database server, no cache, no queue and no second process. (Yggdrasil is
|
||||
self-hosting too — for that route see [As a Rune](rune.md).)
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"andreas": "Andreas",
|
||||
"Andreas": "Andreas",
|
||||
"anne mette": "Anne Mette",
|
||||
" kristian ": "Kristian",
|
||||
"anne mette": "Anne Mette",
|
||||
"McDonald": "McDonald", // an existing inner capital is kept
|
||||
"óscar": "Óscar", // Unicode initial
|
||||
"æblegaard": "Æblegaard",
|
||||
"": "",
|
||||
" ": "",
|
||||
"mette-marie sø": "Mette-marie Sø", // only word-initials are raised
|
||||
"iPhone listen": "IPhone Listen", // deliberately: the first letter is raised
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizeName(in); got != want {
|
||||
t.Errorf("NormalizeName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserCapitalisesName(t *testing.T) {
|
||||
db := openTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := &User{Email: "andreas@example.dk", Name: "andreas", PasswordHash: "x"}
|
||||
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
got, err := db.UserByEmail(ctx, "andreas@example.dk")
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if got.Name != "Andreas" {
|
||||
t.Errorf("stored name = %q, want %q", got.Name, "Andreas")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUserNamesBackfill(t *testing.T) {
|
||||
db := openTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// An account as it looked before the app capitalised names: made properly,
|
||||
// then set back to lower case behind the store's back to stand in for an old row.
|
||||
u := &User{Email: "a@example.dk", Name: "Andreas", PasswordHash: "x"}
|
||||
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE users SET name = 'andreas' WHERE id = ?`, u.ID); err != nil {
|
||||
t.Fatalf("downgrade name: %v", err)
|
||||
}
|
||||
|
||||
fixed, err := db.NormalizeUserNames(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
if fixed != 1 {
|
||||
t.Fatalf("fixed = %d, want 1", fixed)
|
||||
}
|
||||
got, _ := db.UserByID(ctx, u.ID)
|
||||
if got.Name != "Andreas" {
|
||||
t.Errorf("name after backfill = %q, want %q", got.Name, "Andreas")
|
||||
}
|
||||
|
||||
// Idempotent: nothing left to do on a second pass.
|
||||
again, err := db.NormalizeUserNames(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize again: %v", err)
|
||||
}
|
||||
if again != 0 {
|
||||
t.Errorf("second pass fixed = %d, want 0", again)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
)
|
||||
@@ -62,6 +63,26 @@ func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// NormalizeName trims a display name and capitalises the first letter of every
|
||||
// word, leaving the rest of each word exactly as typed. So "andreas" becomes
|
||||
// "Andreas" and "anne mette" becomes "Anne Mette", while a name that already
|
||||
// carries a capital in the middle — "McDonald", an "iPhone" — keeps it: only the
|
||||
// first letter of a word is ever raised, never lowered.
|
||||
//
|
||||
// A name is the person's to spell; the one thing the instance insists on is that
|
||||
// it does not start in lower case, because a column of lower-case first names
|
||||
// reads as a bug the person cannot fix from their own keyboard. Unicode-aware, so
|
||||
// "æblegaard" and "óscar" are raised the same as an ASCII initial.
|
||||
func NormalizeName(name string) string {
|
||||
fields := strings.Fields(name)
|
||||
for i, f := range fields {
|
||||
r := []rune(f)
|
||||
r[0] = unicode.ToUpper(r[0])
|
||||
fields[i] = string(r)
|
||||
}
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
||||
//
|
||||
// The Inbox is created here rather than lazily on first use because every other
|
||||
@@ -70,6 +91,7 @@ func NormalizeEmail(email string) string {
|
||||
// of them end up racing to create the second one.
|
||||
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
u.Name = NormalizeName(u.Name)
|
||||
if u.ID == "" {
|
||||
u.ID = NewID()
|
||||
}
|
||||
@@ -473,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
|
||||
// invites were sent to, and changing it is a re-verification flow rather than a
|
||||
// field. Neither is is_admin — an account cannot promote itself.
|
||||
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
||||
name = NormalizeName(name)
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
||||
name, timezone, locale, time.Now().Unix(), userID)
|
||||
@@ -485,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeUserNames capitalises the first letter of every existing user's name,
|
||||
// for the accounts created before the app started doing it on the way in.
|
||||
//
|
||||
// Idempotent: a name already normalised is left untouched, so it costs one pass
|
||||
// of names at each start and writes nothing once it has caught up. Like SealNotes
|
||||
// it runs at boot rather than as a migration, because the rule lives in Go —
|
||||
// Unicode-aware and per word — and SQLite's own UPPER cannot spell it.
|
||||
//
|
||||
// `updated_at` is not touched: fixing the spelling of a name the person chose is
|
||||
// the instance tidying up after itself, not the person editing their profile.
|
||||
func (db *DB) NormalizeUserNames(ctx context.Context) (int, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type pending struct{ id, name string }
|
||||
var todo []pending
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
if err := rows.Scan(&id, &name); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if n := NormalizeName(name); n != name {
|
||||
todo = append(todo, pending{id, n})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(todo) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
for _, p := range todo {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET name = ? WHERE id = ?`, p.name, p.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(todo), nil
|
||||
}
|
||||
|
||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
||||
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
||||
|
||||
+1
-1
@@ -44,8 +44,8 @@ extra_css:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Setting it up:
|
||||
- Self-hosting: self-hosting.md
|
||||
- As a Rune: rune.md
|
||||
- With Docker: self-hosting.md
|
||||
- Configuration: configuration.md
|
||||
- Using it:
|
||||
- Signing in: signing-in.md
|
||||
|
||||
+5
-2
@@ -856,8 +856,11 @@
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||
Self-host it
|
||||
<a class="button primary" href="https://kristianwind.github.io/verdande/rune/">
|
||||
As a Rune
|
||||
</a>
|
||||
<a class="button" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||
With Docker
|
||||
</a>
|
||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||
|
||||
+28
-3
@@ -86,6 +86,29 @@ test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en URL i en opgave bliver et klikbart link, og resten åbner opgaven', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
// The URL is one token, so the parser leaves it in the title; "i dag" is what it
|
||||
// takes. The link is made at display time — the stored title is still plain text.
|
||||
await box.fill('læs https://example.com/artikel i dag');
|
||||
await box.press('Enter');
|
||||
|
||||
const row = page.locator('.row').filter({ hasText: 'læs' });
|
||||
const link = row.getByRole('link', { name: 'https://example.com/artikel' });
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com/artikel');
|
||||
await expect(link).toHaveAttribute('rel', /noopener/);
|
||||
|
||||
// Clicking the plain part of the title still opens the task; only the link is
|
||||
// carved out. Aimed at the top-left, over "læs", not the URL in the middle.
|
||||
await row.locator('.content').click({ position: { x: 3, y: 3 } });
|
||||
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
@@ -902,6 +925,8 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const andreas = await context.newPage();
|
||||
await andreas.goto(link);
|
||||
// Typed in lower case on purpose: the instance capitalises a name on the way
|
||||
// in, so from here on this person is "Andreas" everywhere they are shown.
|
||||
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
||||
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||
@@ -920,7 +945,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
await page.reload();
|
||||
await projectAction(page, /Delt/);
|
||||
await expect(page.getByText('Ejer')).toBeVisible();
|
||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
||||
await page.getByLabel('Rolle for Andreas').selectOption('editor');
|
||||
|
||||
await andreas.reload();
|
||||
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
||||
@@ -933,11 +958,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('brænde kaffe', { exact: true }).click();
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Andreas' });
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
||||
await expect(row.getByTitle('Uddelegeret til andreas')).toBeVisible();
|
||||
await expect(row.getByTitle('Uddelegeret til Andreas')).toBeVisible();
|
||||
// And not on a task that is nobody's.
|
||||
await page.getByLabel('Ny opgave').fill('min egen');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { repeatLabel } from '$lib/when.js';
|
||||
import { linkify } from '$lib/linkify.js';
|
||||
|
||||
let { task, onedit } = $props();
|
||||
|
||||
@@ -99,13 +100,44 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Opening the detail drawer is the default. `onedit` is for the callers that
|
||||
want the click to mean something else. -->
|
||||
<button class="body" onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}>
|
||||
<span class="content">{task.content}</span>
|
||||
<!-- A URL pasted into the title or the description is clickable, without the
|
||||
text ever having been anything but plain text. The link is a real <a>, which
|
||||
an <a> in a <button> may not be — so the body is a role=button rather than a
|
||||
button, and each link stops its click from also opening the task. Clicking
|
||||
any plain text still opens it. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="body"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onedit ? onedit(task) : app.openDetail(task.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="content"
|
||||
>{#each linkify(task.content) as run}{#if run.href}<a
|
||||
class="tlink"
|
||||
href={run.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}>{run.text}</a
|
||||
>{:else}{run.text}{/if}{/each}</span
|
||||
>
|
||||
|
||||
{#if task.description}
|
||||
<span class="description">{task.description}</span>
|
||||
<span class="description"
|
||||
>{#each linkify(task.description) as run}{#if run.href}<a
|
||||
class="tlink"
|
||||
href={run.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}>{run.text}</a
|
||||
>{:else}{run.text}{/if}{/each}</span
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
|
||||
@@ -186,7 +218,7 @@
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -307,6 +339,13 @@
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.body:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -315,6 +354,20 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* A real link, so it can be middle-clicked, copied and previewed. Its own click
|
||||
is stopped from bubbling, so opening the page does not also open the task. */
|
||||
.tlink {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tlink:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Splits a plain string into runs of text and the URLs inside it, so a task's
|
||||
* title or description can be shown with the links clickable without the text
|
||||
* ever having been anything but plain text on disk.
|
||||
*
|
||||
* A task is not rich text — the title is an `<input>` and the description a
|
||||
* `<textarea>` — so there is nowhere to *store* a link. What there is instead is
|
||||
* a person who pasted one in and expects to be able to click it. This finds the
|
||||
* ones that are unambiguously links and leaves everything else exactly as typed.
|
||||
*
|
||||
* Deliberately conservative about what counts as a link: an explicit
|
||||
* `http://`/`https://`, or a bare `www.` host. "example.com" on its own is not
|
||||
* matched — most of those are sentences ("the file.txt is here"), and a false
|
||||
* link in a title is worse than a real one left plain.
|
||||
*/
|
||||
|
||||
// A trailing `.,;:!?` or a closing bracket is almost always punctuation the
|
||||
// sentence owns rather than part of the URL, so the run stops before it. A `)`
|
||||
// is only dropped when the URL holds no `(` of its own — Wikipedia-style links
|
||||
// carry balanced parentheses that belong to the address.
|
||||
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>]+/gi;
|
||||
|
||||
function trimTrailing(url) {
|
||||
let end = url.length;
|
||||
while (end > 0) {
|
||||
const ch = url[end - 1];
|
||||
if ('.,;:!?"\''.includes(ch)) {
|
||||
end--;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')' && !url.slice(0, end).includes('(')) {
|
||||
end--;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return url.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {Array<{ text: string, href?: string }>} runs in order; a run with an
|
||||
* `href` is a link, one without is plain text. An empty or link-free string
|
||||
* comes back as a single text run (possibly empty) so callers can render it the
|
||||
* same way regardless.
|
||||
*/
|
||||
export function linkify(text) {
|
||||
const value = text ?? '';
|
||||
const runs = [];
|
||||
let last = 0;
|
||||
|
||||
for (const match of value.matchAll(URL_RE)) {
|
||||
const raw = trimTrailing(match[0]);
|
||||
if (!raw) continue;
|
||||
const start = match.index;
|
||||
|
||||
if (start > last) runs.push({ text: value.slice(last, start) });
|
||||
|
||||
const href = raw.startsWith('www.') ? `https://${raw}` : raw;
|
||||
runs.push({ text: raw, href });
|
||||
last = start + raw.length;
|
||||
}
|
||||
|
||||
if (last < value.length) runs.push({ text: value.slice(last) });
|
||||
if (!runs.length) runs.push({ text: value });
|
||||
|
||||
return runs;
|
||||
}
|
||||
@@ -266,6 +266,10 @@ export const da = {
|
||||
'Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift adgangskode.',
|
||||
'account.thisDevice': 'denne enhed',
|
||||
'account.signOutDevice': 'Log ud',
|
||||
'account.support': 'Støt projektet',
|
||||
'account.supportHint':
|
||||
'verdande er fri, open source-software under MIT-licensen — ingen betalt version, intet holdt tilbage. Sparede den dig en eftermiddag, kan du give vedligeholderen en kop kaffe. Helt frivilligt, og intet i appen ændrer sig.',
|
||||
'account.buyCoffee': 'Buy me a coffee',
|
||||
|
||||
'account.totpEnabled': 'Slået til.',
|
||||
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
|
||||
|
||||
@@ -262,6 +262,10 @@ export const en = {
|
||||
'Where you are signed in. If you do not recognise one, sign it out and change your password.',
|
||||
'account.thisDevice': 'this device',
|
||||
'account.signOutDevice': 'Sign out',
|
||||
'account.support': 'Support the project',
|
||||
'account.supportHint':
|
||||
'verdande is free, open-source software under the MIT licence — no paid tier, nothing held back. If it saved you an afternoon, you can buy the maintainer a coffee. Entirely optional, and nothing in the app changes either way.',
|
||||
'account.buyCoffee': 'Buy me a coffee',
|
||||
|
||||
'account.totpEnabled': 'On.',
|
||||
'account.recoveryLeftOne': '{n} recovery code left.',
|
||||
|
||||
@@ -571,6 +571,42 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Last, and quietly: a donate link belongs in Settings, never in the way of the
|
||||
work. A plain anchor with an inline SVG — no third-party script, no webfont,
|
||||
no tracking — so it works offline and cannot phone home. See the coffee-cup
|
||||
snippet for why not their generated button. -->
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('account.support')}</h2>
|
||||
<p class="hint">{t('account.supportHint')}</p>
|
||||
</header>
|
||||
|
||||
<a
|
||||
class="bmc"
|
||||
href="https://buymeacoffee.com/kristianwind"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M4 9h13v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9Z" />
|
||||
<path d="M17 10h1.5a2.5 2.5 0 0 1 0 5H17" />
|
||||
<path d="M7.5 5.6c0-.9.8-1.1.8-2M11 5.6c0-.9.8-1.1.8-2M14.5 5.6c0-.9.8-1.1.8-2" />
|
||||
</svg>
|
||||
{t('account.buyCoffee')} ↗
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.looks {
|
||||
margin-top: var(--s4);
|
||||
@@ -771,4 +807,36 @@
|
||||
.uri {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The understated variant: present, not loud. Bordered and in the muted ink so
|
||||
it reads as a footnote, brightening on hover. The cup is outline-only. */
|
||||
.bmc {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
transition:
|
||||
color var(--fast) var(--ease),
|
||||
border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.bmc:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.bmc:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bmc svg {
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user