Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b037c7f4 | ||
|
|
4103e1b0ca | ||
|
|
9d4a13c2c2 | ||
|
|
923f911472 | ||
|
|
272c4918b0 | ||
|
|
ff866cc514 |
@@ -86,6 +86,29 @@ test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }
|
|||||||
expect(trouble).toEqual([]);
|
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 }) => {
|
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { repeatLabel } from '$lib/when.js';
|
import { repeatLabel } from '$lib/when.js';
|
||||||
|
import { linkify } from '$lib/linkify.js';
|
||||||
|
|
||||||
let { task, onedit } = $props();
|
let { task, onedit } = $props();
|
||||||
|
|
||||||
@@ -99,13 +100,44 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Opening the detail drawer is the default. `onedit` is for the callers that
|
<!-- A URL pasted into the title or the description is clickable, without the
|
||||||
want the click to mean something else. -->
|
text ever having been anything but plain text. The link is a real <a>, which
|
||||||
<button class="body" onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}>
|
an <a> in a <button> may not be — so the body is a role=button rather than a
|
||||||
<span class="content">{task.content}</span>
|
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}
|
{#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}
|
||||||
|
|
||||||
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
|
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
|
||||||
@@ -186,7 +218,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -307,6 +339,13 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -315,6 +354,20 @@
|
|||||||
overflow-wrap: anywhere;
|
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 {
|
.description {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--ink-muted);
|
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.',
|
'Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift adgangskode.',
|
||||||
'account.thisDevice': 'denne enhed',
|
'account.thisDevice': 'denne enhed',
|
||||||
'account.signOutDevice': 'Log ud',
|
'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.totpEnabled': 'Slået til.',
|
||||||
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
|
'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.',
|
'Where you are signed in. If you do not recognise one, sign it out and change your password.',
|
||||||
'account.thisDevice': 'this device',
|
'account.thisDevice': 'this device',
|
||||||
'account.signOutDevice': 'Sign out',
|
'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.totpEnabled': 'On.',
|
||||||
'account.recoveryLeftOne': '{n} recovery code left.',
|
'account.recoveryLeftOne': '{n} recovery code left.',
|
||||||
|
|||||||
@@ -571,6 +571,42 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
<style>
|
||||||
.looks {
|
.looks {
|
||||||
margin-top: var(--s4);
|
margin-top: var(--s4);
|
||||||
@@ -771,4 +807,36 @@
|
|||||||
.uri {
|
.uri {
|
||||||
overflow-wrap: anywhere;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user