Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0998fe524 | ||
|
|
8dfab618f7 | ||
|
|
c0c1281415 | ||
|
|
247277e139 | ||
|
|
f3b037c7f4 | ||
|
|
4103e1b0ca | ||
|
|
9d4a13c2c2 |
@@ -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))
|
||||
|
||||
|
||||
@@ -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: "[email protected]", Name: "andreas", PasswordHash: "x"}
|
||||
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
got, err := db.UserByEmail(ctx, "[email protected]")
|
||||
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: "[email protected]", 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
|
||||
|
||||
+60
-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('/');
|
||||
@@ -222,6 +245,38 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('menu- og brødtekststørrelse sættes hver for sig og overlever en genindlæsning', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
// Two independent knobs under Udseende — the sidebar's size, and the body text's.
|
||||
await page.getByLabel('Menu', { exact: true }).selectOption('large');
|
||||
await page.getByLabel('Brødtekst', { exact: true }).selectOption('xl');
|
||||
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
|
||||
|
||||
const attrs = () =>
|
||||
page.evaluate(() => [
|
||||
document.documentElement.dataset.menuSize,
|
||||
document.documentElement.dataset.textSize
|
||||
]);
|
||||
expect(await attrs()).toEqual(['large', 'xl']);
|
||||
|
||||
// They live in localStorage like the theme, so they survive a reload — and are
|
||||
// applied before first paint, so the picker comes back showing the saved choice.
|
||||
await page.reload();
|
||||
expect(await attrs()).toEqual(['large', 'xl']);
|
||||
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
|
||||
|
||||
// Put them back, so the shared account does not leak a size into other tests.
|
||||
await page.getByLabel('Menu', { exact: true }).selectOption('default');
|
||||
await page.getByLabel('Brødtekst', { exact: true }).selectOption('default');
|
||||
expect(await attrs()).toEqual([undefined, undefined]);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The PWA's own files. Both of the bugs found last in this project were of this
|
||||
* kind — a manifest promising icons that were never generated — and they are
|
||||
@@ -902,6 +957,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 +977,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 +990,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');
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
--text-xl: 1.375rem;
|
||||
--text-2xl: 1.75rem;
|
||||
|
||||
/* The sidebar's own text size, its own knob (Settings → Appearance) so the menu
|
||||
can be made easier to read without dragging the whole list with it. Default is
|
||||
a step up from the old --text-sm the nav used, at medium weight, so the menu
|
||||
items read as menu items. [data-menu-size] on <html> overrides it. */
|
||||
--menu-size: 0.9375rem;
|
||||
|
||||
/* Spacing on a 4px grid. */
|
||||
--s1: 0.25rem;
|
||||
--s2: 0.5rem;
|
||||
@@ -516,3 +522,41 @@ button {
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Body text size — its own control in Settings → Appearance, for reading the list
|
||||
and the notes larger (or smaller) without touching the menu. It re-scales the
|
||||
shared type tokens the same way a look does; placed after the looks so that an
|
||||
explicit size choice wins the tokens they both set. The menu has its own knob
|
||||
below and does not move with this one. */
|
||||
[data-text-size='small'] {
|
||||
--text-base: 0.875rem;
|
||||
--text-sm: 0.75rem;
|
||||
--text-lg: 1rem;
|
||||
}
|
||||
|
||||
[data-text-size='large'] {
|
||||
--text-base: 1.0625rem;
|
||||
--text-sm: 0.9375rem;
|
||||
--text-lg: 1.1875rem;
|
||||
}
|
||||
|
||||
[data-text-size='xl'] {
|
||||
--text-base: 1.1875rem;
|
||||
--text-sm: 1.0625rem;
|
||||
--text-lg: 1.3125rem;
|
||||
}
|
||||
|
||||
/* Menu (sidebar) font size — independent of the body size above, because the two
|
||||
are separate questions: a menu you can read at a glance, and a list you can read
|
||||
a paragraph of. */
|
||||
[data-menu-size='small'] {
|
||||
--menu-size: 0.8125rem;
|
||||
}
|
||||
|
||||
[data-menu-size='large'] {
|
||||
--menu-size: 1.0625rem;
|
||||
}
|
||||
|
||||
[data-menu-size='xl'] {
|
||||
--menu-size: 1.1875rem;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
// face and again in another, which is a visible jump.
|
||||
const look = localStorage.getItem('verdande:look');
|
||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||
|
||||
// The font-size choices change layout too, so they belong before first
|
||||
// paint for the same reason: otherwise the sidebar and the list are drawn
|
||||
// once at one size and again at another.
|
||||
const menuSize = localStorage.getItem('verdande:menu-size');
|
||||
if (menuSize && menuSize !== 'default') document.documentElement.dataset.menuSize = menuSize;
|
||||
const textSize = localStorage.getItem('verdande:text-size');
|
||||
if (textSize && textSize !== 'default') document.documentElement.dataset.textSize = textSize;
|
||||
} catch (e) {
|
||||
// Private browsing can refuse localStorage; the dark default stands.
|
||||
}
|
||||
|
||||
@@ -1441,7 +1441,10 @@
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
/* Its own size (Settings → Appearance) and medium weight, so the menu items
|
||||
carry more than the muted small text they used to and read at a glance. */
|
||||
font-size: var(--menu-size);
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background var(--fast) var(--ease),
|
||||
color var(--fast) var(--ease);
|
||||
@@ -1455,7 +1458,7 @@
|
||||
a.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Only your own projects reorder. A shared one sits where its owner put it —
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -230,6 +230,14 @@ export const da = {
|
||||
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
|
||||
'account.look': 'Udseende',
|
||||
'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.',
|
||||
'account.sizes': 'Skriftstørrelse',
|
||||
'account.sizesHint': 'Menuen og brødteksten kan sættes hver for sig — den ene er et blik ned over en liste, den anden en tekst, man læser.',
|
||||
'account.menuSize': 'Menu',
|
||||
'account.textSize': 'Brødtekst',
|
||||
'size.small': 'Lille',
|
||||
'size.default': 'Standard',
|
||||
'size.large': 'Stor',
|
||||
'size.xl': 'Størst',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdandeNote': 'Som du kender den.',
|
||||
'look.rolig': 'Rolig',
|
||||
|
||||
@@ -226,6 +226,14 @@ export const en = {
|
||||
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
|
||||
'account.look': 'Appearance',
|
||||
'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.',
|
||||
'account.sizes': 'Text size',
|
||||
'account.sizesHint': 'The menu and the body text set separately — one is a glance down a list, the other a paragraph you read.',
|
||||
'account.menuSize': 'Menu',
|
||||
'account.textSize': 'Body text',
|
||||
'size.small': 'Small',
|
||||
'size.default': 'Default',
|
||||
'size.large': 'Large',
|
||||
'size.xl': 'Largest',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdandeNote': 'As you know it.',
|
||||
'look.rolig': 'Calm',
|
||||
|
||||
@@ -1051,6 +1051,52 @@ class Look {
|
||||
|
||||
export const look = new Look();
|
||||
|
||||
/**
|
||||
* The font-size steps, shared by the menu and the body-text controls. `default`
|
||||
* carries no attribute, so the base tokens in app.css stand unchanged.
|
||||
*/
|
||||
export const SIZES = [
|
||||
{ id: 'small', name: 'size.small' },
|
||||
{ id: 'default', name: 'size.default' },
|
||||
{ id: 'large', name: 'size.large' },
|
||||
{ id: 'xl', name: 'size.xl' }
|
||||
];
|
||||
|
||||
/**
|
||||
* A font-size preference — the same shape as Look: kept in localStorage and
|
||||
* mirrored onto <html>, where app.html sets it before first paint so a chosen size
|
||||
* does not flash in at the default and resize. Two instances below, because the
|
||||
* menu and the body text are separate questions.
|
||||
*/
|
||||
class Sizing {
|
||||
current = $state('default');
|
||||
#key;
|
||||
#attr;
|
||||
|
||||
constructor(key, attr) {
|
||||
this.#key = key;
|
||||
this.#attr = attr;
|
||||
if (typeof document !== 'undefined') {
|
||||
this.current = document.documentElement.dataset[attr] ?? 'default';
|
||||
}
|
||||
}
|
||||
|
||||
set(id) {
|
||||
if (!SIZES.some((s) => s.id === id)) return;
|
||||
this.current = id;
|
||||
if (id === 'default') delete document.documentElement.dataset[this.#attr];
|
||||
else document.documentElement.dataset[this.#attr] = id;
|
||||
try {
|
||||
localStorage.setItem(this.#key, id);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const menuSize = new Sizing('verdande:menu-size', 'menuSize');
|
||||
export const textSize = new Sizing('verdande:text-size', 'textSize');
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
class Theme {
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
/** Konto: who you are, your password, and the second factor. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, theme, THEMES, look, LOOKS } from '$lib/stores.svelte.js';
|
||||
import { app, theme, THEMES, look, LOOKS, menuSize, textSize, SIZES } from '$lib/stores.svelte.js';
|
||||
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||
import { supported, register } from '$lib/passkey.js';
|
||||
import { ago } from '$lib/when.js';
|
||||
@@ -362,6 +362,40 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two more axes: how large the menu reads and how large the body text reads.
|
||||
Separate on purpose — one is a glance down a list of names, the other is a
|
||||
paragraph you sit and read. -->
|
||||
<div class="sizes">
|
||||
<h3>{t('account.sizes')}</h3>
|
||||
<p class="hint">{t('account.sizesHint')}</p>
|
||||
<div class="size-row">
|
||||
<div class="size-field">
|
||||
<label for="menu-size">{t('account.menuSize')}</label>
|
||||
<select
|
||||
id="menu-size"
|
||||
value={menuSize.current}
|
||||
onchange={(e) => menuSize.set(e.currentTarget.value)}
|
||||
>
|
||||
{#each SIZES as option (option.id)}
|
||||
<option value={option.id}>{t(option.name)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="size-field">
|
||||
<label for="text-size">{t('account.textSize')}</label>
|
||||
<select
|
||||
id="text-size"
|
||||
value={textSize.current}
|
||||
onchange={(e) => textSize.set(e.currentTarget.value)}
|
||||
>
|
||||
{#each SIZES as option (option.id)}
|
||||
<option value={option.id}>{t(option.name)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -619,6 +653,37 @@
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.sizes {
|
||||
margin-top: var(--s4);
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sizes h3 {
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.size-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--s3);
|
||||
margin-top: var(--s3);
|
||||
}
|
||||
|
||||
.size-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.size-field label {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.look-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user