Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2fcc0b104 | ||
|
|
8dc601d09a | ||
|
|
85e769a603 | ||
|
|
0363d5f422 | ||
|
|
05b7dac713 | ||
|
|
cfd382e3df | ||
|
|
bbcff40401 | ||
|
|
01001d0980 |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
@@ -565,8 +565,13 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
|||||||
await expect(section.getByText('brænde kaffe')).toBeVisible();
|
await expect(section.getByText('brænde kaffe')).toBeVisible();
|
||||||
await expect(page.locator('.toast')).toHaveCount(0);
|
await expect(page.locator('.toast')).toHaveCount(0);
|
||||||
|
|
||||||
// A load that fails says so, and offers to try again.
|
// A load that fails with a server error says so at once and offers to try again.
|
||||||
await page.route('**/api/v1/projects/*', (r) => r.abort());
|
// A 500 is the server answering, not a dropped connection, so it is not a blip to
|
||||||
|
// wait out — it is not retried, and the message is immediate. (A dropped
|
||||||
|
// connection now retries for a few seconds first; that is the self-heal test.)
|
||||||
|
await page.route('**/api/v1/projects/*', (r) =>
|
||||||
|
r.fulfill({ status: 500, contentType: 'application/json', body: '{"code":"internal_error"}' })
|
||||||
|
);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
||||||
@@ -577,6 +582,52 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
|||||||
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
|
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A save made during a short drop is not lost to it.
|
||||||
|
*
|
||||||
|
* This is the whole of what "offline" means here: not a cache to work in with no
|
||||||
|
* server, but a save that rides out a few seconds of no connection and goes through
|
||||||
|
* when it comes back — with a quiet "reconnecting" while it waits, and no error and
|
||||||
|
* no reverted change once it heals. A drop that outlasts the retry window is a real
|
||||||
|
* outage and takes the normal error path; that is not this test.
|
||||||
|
*/
|
||||||
|
test('en kort netaudsætning under en handling genoprettes af sig selv', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait for the app to finish loading before cutting the line — `goto` resolves at
|
||||||
|
// the load event, but the data load runs after hydration, and aborting that would
|
||||||
|
// test a cold start, not a blip mid-use.
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await expect(box).toBeVisible();
|
||||||
|
|
||||||
|
// The app is loaded and online. Now the connection drops — every call fails, the
|
||||||
|
// way it does in a tunnel, rather than one endpoint erroring.
|
||||||
|
let down = true;
|
||||||
|
await page.route('**/api/v1/**', (route) => (down ? route.abort() : route.continue()));
|
||||||
|
|
||||||
|
// A task captured during the drop. Its save fails on the network and starts
|
||||||
|
// retrying rather than giving up.
|
||||||
|
await box.fill('under et udfald');
|
||||||
|
await box.press('Enter');
|
||||||
|
|
||||||
|
// The quiet pill, once the retry has lasted long enough to be worth showing.
|
||||||
|
await expect(page.locator('.netstatus')).toBeVisible();
|
||||||
|
|
||||||
|
// The drop ends. The retry goes through on its own and the pill leaves — no
|
||||||
|
// error, nothing toasted as failed.
|
||||||
|
down = false;
|
||||||
|
await expect(page.locator('.netstatus')).toHaveCount(0, { timeout: 10000 });
|
||||||
|
await expect(page.locator('.toast')).toHaveCount(0);
|
||||||
|
|
||||||
|
// And the save landed: an undated quick capture goes to the Inbox, and there it
|
||||||
|
// is, saved across the drop rather than lost to it.
|
||||||
|
await page.getByRole('link', { name: /Indbakke/ }).click();
|
||||||
|
await expect(page.getByText('under et udfald')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A task can be dragged onto another day, and onto another project.
|
* A task can be dragged onto another day, and onto another project.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -23,6 +23,57 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { t } from './i18n.svelte.js';
|
import { t } from './i18n.svelte.js';
|
||||||
|
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a request keeps retrying a network failure before giving up.
|
||||||
|
*
|
||||||
|
* This is the whole of the "offline" feature and its whole limit: it rides out a
|
||||||
|
* blip of a few seconds so a save is not lost to it, and then — because the app is
|
||||||
|
* a live view of a shared list, not a local database — it stops and says so rather
|
||||||
|
* than holding an edit for a network that is not coming back. Fifteen seconds is
|
||||||
|
* long enough for a handover or a tunnel and short enough that a real outage fails
|
||||||
|
* while the user is still looking at what they did.
|
||||||
|
*/
|
||||||
|
const RETRY_WINDOW_MS = 15000;
|
||||||
|
|
||||||
|
/** Backoff between attempts: 0.5s, 1s, 2s, then 3s, capped. A waiting sleep also
|
||||||
|
* wakes the instant the OS reports a link again, so most blips end on the first. */
|
||||||
|
const backoff = (attempt) => Math.min(500 * 2 ** attempt, 3000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `send` (a fetch), retrying only network failures for a short window.
|
||||||
|
*
|
||||||
|
* A network failure is fetch rejecting — no HTTP status ever arrived. An HTTP
|
||||||
|
* error (4xx/5xx) is the server answering, so it is returned, not retried: retrying
|
||||||
|
* a 400 just asks the same wrong question again. An abort is the caller changing
|
||||||
|
* its mind (a superseded search), so it is left to propagate.
|
||||||
|
*
|
||||||
|
* Retrying is not free of risk: a create whose request reached the server but whose
|
||||||
|
* response was lost would be made twice. For a drop of seconds that is rare, and the
|
||||||
|
* alternative — losing the save every time the network hiccups — is the thing this
|
||||||
|
* exists to stop. A server-side idempotency key would remove even that; it is a
|
||||||
|
* larger change noted for later.
|
||||||
|
*/
|
||||||
|
async function withRetry(send, noRetry = false) {
|
||||||
|
const deadline = Date.now() + (noRetry ? 0 : RETRY_WINDOW_MS);
|
||||||
|
let attempt = 0;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const res = await send();
|
||||||
|
connectivity.reachable();
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'AbortError') throw e;
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
connectivity.lost();
|
||||||
|
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||||
|
}
|
||||||
|
connectivity.retrying();
|
||||||
|
await sleepOrOnline(backoff(attempt++));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key for each code the API can return.
|
* The key for each code the API can return.
|
||||||
@@ -110,14 +161,10 @@ async function request(method, path, body, options = {}) {
|
|||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
let res;
|
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||||
try {
|
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||||
res = await fetch(`/api/v1${path}`, init);
|
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||||
} catch (e) {
|
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||||
// A network failure is not an HTTP status; give it one the app can handle
|
|
||||||
// the same way as everything else.
|
|
||||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
|
|
||||||
@@ -157,12 +204,9 @@ async function upload(path, file, field = 'file') {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append(field, file);
|
form.append(field, file);
|
||||||
|
|
||||||
let res;
|
const res = await withRetry(() =>
|
||||||
try {
|
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||||
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
|
);
|
||||||
} catch {
|
|
||||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let payload = null;
|
let payload = null;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Connectivity, as the app experiences it — not as the browser reports it.
|
||||||
|
*
|
||||||
|
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
|
||||||
|
* handing over. It is deliberately NOT offline support. The app is already loaded
|
||||||
|
* and its data is already in memory, so there is nothing to cache and nothing to
|
||||||
|
* open from cold; what a blip needs is for the save you just made to survive it,
|
||||||
|
* not for the whole app to run without a server.
|
||||||
|
*
|
||||||
|
* The request layer drives this: it retries a failed request for a short window
|
||||||
|
* (see api.js) and tells this store while it is trying, so the interface can say
|
||||||
|
* "reconnecting" rather than reverting the change under the user's hands. If the
|
||||||
|
* window runs out it is a real outage, and then the normal error path takes over.
|
||||||
|
*
|
||||||
|
* `navigator.onLine` is only a hint — it goes false when the machine has no route,
|
||||||
|
* but it stays true for a dead gateway or a server that is down, so it cannot be
|
||||||
|
* the source of truth. It is used here only to wake a waiting retry the moment the
|
||||||
|
* OS sees a link again, which is the one thing it is reliable for.
|
||||||
|
*/
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
class Connectivity {
|
||||||
|
/** 'online' — every recent request reached the server.
|
||||||
|
* 'retrying' — a request failed on the network and is being retried.
|
||||||
|
* 'lost' — a request gave up after the retry window; a real outage. */
|
||||||
|
status = $state('online');
|
||||||
|
|
||||||
|
/** A request reached the server. The network is up, whatever it was doing. */
|
||||||
|
reachable() {
|
||||||
|
this.status = 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request failed on the network and is about to be tried again. */
|
||||||
|
retrying() {
|
||||||
|
if (this.status !== 'lost') this.status = 'retrying';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request used up its retry window without reaching the server. */
|
||||||
|
lost() {
|
||||||
|
this.status = 'lost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectivity = new Connectivity();
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
// The OS lost its link: say so at once rather than waiting for the next request
|
||||||
|
// to fail. It does not, on its own, mean the server is unreachable — but with no
|
||||||
|
// route there is nothing a save can do but wait, and the honest thing is to show
|
||||||
|
// it waiting.
|
||||||
|
window.addEventListener('offline', () => connectivity.retrying());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleeps for `ms`, but wakes early the moment the OS reports a link again — so a
|
||||||
|
* retry that is biding its time does not sit out the last two seconds of a blip
|
||||||
|
* that has already ended.
|
||||||
|
*/
|
||||||
|
export function sleepOrOnline(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let timer;
|
||||||
|
const done = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (browser) window.removeEventListener('online', done);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
timer = setTimeout(done, ms);
|
||||||
|
if (browser) window.addEventListener('online', done, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -130,6 +130,8 @@ export const da = {
|
|||||||
'nav.toggleTheme': 'Skift mellem lyst og mørkt tema',
|
'nav.toggleTheme': 'Skift mellem lyst og mørkt tema',
|
||||||
'nav.offline': 'Offline',
|
'nav.offline': 'Offline',
|
||||||
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
||||||
|
'net.reconnecting': 'Genopretter forbindelse…',
|
||||||
|
'net.offline': 'Ingen forbindelse',
|
||||||
'nav.doItToday': 'Gør det i dag',
|
'nav.doItToday': 'Gør det i dag',
|
||||||
|
|
||||||
// --- the sidebar's own sections ------------------------------------------------
|
// --- the sidebar's own sections ------------------------------------------------
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export const en = {
|
|||||||
'nav.toggleTheme': 'Switch between light and dark',
|
'nav.toggleTheme': 'Switch between light and dark',
|
||||||
'nav.offline': 'Offline',
|
'nav.offline': 'Offline',
|
||||||
'nav.offlineHint': 'Changes from other people are not showing right now',
|
'nav.offlineHint': 'Changes from other people are not showing right now',
|
||||||
|
'net.reconnecting': 'Reconnecting…',
|
||||||
|
'net.offline': 'No connection',
|
||||||
'nav.doItToday': 'Do it today',
|
'nav.doItToday': 'Do it today',
|
||||||
|
|
||||||
// --- the sidebar's own sections ------------------------------------------------
|
// --- the sidebar's own sections ------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||||
|
import { connectivity } from '$lib/connectivity.svelte.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
@@ -14,6 +15,29 @@
|
|||||||
let paletteOpen = $state(false);
|
let paletteOpen = $state(false);
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the "reconnecting" pill.
|
||||||
|
*
|
||||||
|
* Held back seven tenths of a second while merely retrying, so a blip that ends
|
||||||
|
* on the first retry never flashes a pill nobody needed to see. A retry that has
|
||||||
|
* already given up ('lost') is a real error and shown at once — the delay is only
|
||||||
|
* for the hopeful case.
|
||||||
|
*/
|
||||||
|
let showNet = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const s = connectivity.status;
|
||||||
|
if (s === 'online') {
|
||||||
|
showNet = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s === 'lost') {
|
||||||
|
showNet = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => (showNet = true), 700);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two pages that arrive by email, and are for somebody who is not signed in.
|
* The two pages that arrive by email, and are for somebody who is not signed in.
|
||||||
*
|
*
|
||||||
@@ -204,6 +228,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- A short drop, said quietly: it appears only if a save is still retrying after
|
||||||
|
the better part of a second, and leaves the moment the connection is back — so
|
||||||
|
a two-second blip that heals itself is felt as nothing at all. -->
|
||||||
|
{#if showNet}
|
||||||
|
<div class="netstatus" class:lost={connectivity.status === 'lost'} role="status">
|
||||||
|
<span class="net-dot" aria-hidden="true"></span>
|
||||||
|
{connectivity.status === 'lost' ? t('net.offline') : t('net.reconnecting')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -378,6 +412,62 @@
|
|||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The reconnecting pill: at the top, small, centred. Not a toast — a toast is a
|
||||||
|
thing that happened and is over, this is a thing still happening. */
|
||||||
|
.netstatus {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--s3) + env(safe-area-inset-top));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s1) var(--s3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.14);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.net-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-amber);
|
||||||
|
animation: net-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Given up: an amber pulse becomes a still red, the same red a failed toast
|
||||||
|
carries, so "still trying" and "gave up" do not look alike. */
|
||||||
|
.netstatus.lost {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.netstatus.lost .net-dot {
|
||||||
|
background: var(--p1);
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes net-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.net-dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
<p class="hint">{t('data.doneHint')}</p>
|
<p class="hint">{t('data.doneHint')}</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a class="ghost" href="/faerdige">{t('data.openDone')}</a>
|
<a class="link" href="/faerdige">{t('data.openDone')}</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,10 @@
|
|||||||
if (days === 1) return { key: 'i-gaar', label: t('notes.groupYesterday') };
|
if (days === 1) return { key: 'i-gaar', label: t('notes.groupYesterday') };
|
||||||
if (days < 7) return { key: 'ugen', label: t('notes.groupWeek') };
|
if (days < 7) return { key: 'ugen', label: t('notes.groupWeek') };
|
||||||
|
|
||||||
const label = at.toLocaleDateString(tag(), { month: 'long', year: 'numeric' });
|
// Stort forbogstav på måneden: Intl giver "august 2026" på dansk, og en
|
||||||
|
// overskrift begynder med stort.
|
||||||
|
const month = at.toLocaleDateString(tag(), { month: 'long', year: 'numeric' });
|
||||||
|
const label = month.charAt(0).toUpperCase() + month.slice(1);
|
||||||
return { key: `m-${at.getFullYear()}-${at.getMonth()}`, label };
|
return { key: `m-${at.getFullYear()}-${at.getMonth()}`, label };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,41 +838,38 @@
|
|||||||
ondragstart={(e) => onDragStart(e, note)}
|
ondragstart={(e) => onDragStart(e, note)}
|
||||||
ondragend={() => (dragging = null)}
|
ondragend={() => (dragging = null)}
|
||||||
>
|
>
|
||||||
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
|
<!-- Tre linjer, som Apple Noter: titlen, så datoen og uddraget, og
|
||||||
i gruppens overskrift og ikke her — undtagen når der er sorteret
|
nederst hvor noten er lagt. Datoen står forrest på linje to og
|
||||||
på navn, for så er der ingen overskrift, der siger den, og så er
|
venstrestillet, ikke ude til højre. -->
|
||||||
den det eneste tilbage, der gør. -->
|
|
||||||
<span class="head">
|
<span class="head">
|
||||||
<strong>{note.title || t('notes.untitled')}</strong>
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
{#if order === 'title'}
|
</span>
|
||||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
<span class="preview">
|
||||||
{/if}
|
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||||
{#if note.project_id}
|
<span class="snippet">{preview(note)}</span>
|
||||||
<!-- Projektets egen farve, den samme som prikken i sidebjælken.
|
</span>
|
||||||
Mærkaten sagde det samme som alle de andre i en grå, der var
|
<!-- Linje tre: hvor noten er lagt, med den samme mappe som grupperne i
|
||||||
lysere end uddraget — og projektet er dét, man skanner efter,
|
menuen — projektets navn, ejerens hvis den er en andens, ellers bare
|
||||||
når man leder i tolv hundrede noter. Nu kan Hjem kendes fra
|
"Noter", når den ikke hører til et projekt. -->
|
||||||
Claude uden at læse ordet. -->
|
<span class="filed">
|
||||||
<span class="filed" style="--project: {projectColour(note.project_id)}">
|
|
||||||
<span class="dot" aria-hidden="true"></span>
|
|
||||||
{projectName(note.project_id)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if note.shared_with_me && note.owner}
|
{#if note.shared_with_me && note.owner}
|
||||||
<!-- Hvis note delt med mig: hvem den kom fra, i personens egen
|
<svg class="filed-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
avatar-farve. Samme form som projekt-mærkaten, så listen har
|
<circle cx="8" cy="6" r="2.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||||
ét sprog for "det her hører til nogen". -->
|
<path d="M3.6 13c0-2.5 2-4 4.4-4s4.4 1.5 4.4 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||||
<span
|
</svg>
|
||||||
class="filed person"
|
<span class="filed-name" title={t('notes.sharedBy', { name: note.owner.name })}>{note.owner.name}</span>
|
||||||
style="--project: {note.owner.avatar_color}"
|
{:else if note.project_id}
|
||||||
title={t('notes.sharedBy', { name: note.owner.name })}
|
<svg class="filed-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
>
|
<path d="M2 4.6a1 1 0 011-1h3.1l1.4 1.5H13a1 1 0 011 1v6.3a1 1 0 01-1 1H3a1 1 0 01-1-1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
|
||||||
<span class="dot" aria-hidden="true"></span>
|
</svg>
|
||||||
{note.owner.name}
|
<span class="filed-name">{projectName(note.project_id)}</span>
|
||||||
</span>
|
{:else}
|
||||||
|
<svg class="filed-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M2 4.6a1 1 0 011-1h3.1l1.4 1.5H13a1 1 0 011 1v6.3a1 1 0 01-1 1H3a1 1 0 01-1-1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span class="filed-name">{t('notes.title')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="preview">{preview(note)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<!-- De to knapper står oven på hinanden i en smal søjle.
|
<!-- De to knapper står oven på hinanden i en smal søjle.
|
||||||
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||||
@@ -1326,13 +1326,20 @@
|
|||||||
* målt på en rigtig række: titel plus én linje uddrag. */
|
* målt på en rigtig række: titel plus én linje uddrag. */
|
||||||
li {
|
li {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: auto 54px;
|
contain-intrinsic-size: auto 74px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A faint hairline between notes, as Apple Notes has — the group boxes give the
|
||||||
|
coarse structure, this separates one note from the next inside a group. */
|
||||||
.rowline {
|
.rowline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s1);
|
gap: var(--s1);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
li:last-child .rowline {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
|
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
|
||||||
@@ -1490,19 +1497,25 @@
|
|||||||
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
||||||
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
|
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
|
||||||
|
|
||||||
|
/* Datoen forrest på linje to og venstrestillet, som Apple Noter — ikke skubbet
|
||||||
|
ud til højre. Tabular-tal, så en søjle af datoer står på lige linjer. */
|
||||||
.when {
|
.when {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* `display: block`, og det er ikke pynt.
|
/* Linje to er en flex-række: datoen, der ikke krymper, og uddraget, der klippes.
|
||||||
*
|
Afkortningen skal stå på uddraget selv — et inline-element klippes ikke af
|
||||||
* Uddraget lå før inde i en flex-boks, der ejede afkortningen. Da datoen flyttede
|
`overflow: hidden`. */
|
||||||
* op i gruppens overskrift, forsvandt den boks — og et inline-element klippes
|
|
||||||
* ikke af `overflow: hidden`, så den sidste sætning løb videre ind under
|
|
||||||
* favoritstjernen. Reglerne skal stå på det element, der faktisk skal klippes. */
|
|
||||||
.preview {
|
.preview {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1519,31 +1532,30 @@
|
|||||||
* Prikken bærer farven, ikke teksten. Fem projekters farver som skriftfarve på
|
* Prikken bærer farven, ikke teksten. Fem projekters farver som skriftfarve på
|
||||||
* en lys flade er fem forskellige grader af læsbarhed, og den lyseste af dem er
|
* en lys flade er fem forskellige grader af læsbarhed, og den lyseste af dem er
|
||||||
* ikke læsbar. */
|
* ikke læsbar. */
|
||||||
|
/* Linje tre: hvor noten er lagt. En mappe som grupperne i menuen og et navn,
|
||||||
|
venstrestillet under uddraget — ikke en mærkat skubbet ud til højre. */
|
||||||
.filed {
|
.filed {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375em;
|
gap: 0.4em;
|
||||||
flex: none;
|
min-width: 0;
|
||||||
margin-left: auto;
|
margin-top: 1px;
|
||||||
max-width: 45%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filed .dot {
|
.filed-icon {
|
||||||
width: 6px;
|
width: 13px;
|
||||||
height: 6px;
|
height: 13px;
|
||||||
flex: none;
|
flex: none;
|
||||||
border-radius: var(--radius-full);
|
color: var(--ink-faint);
|
||||||
background: var(--project, var(--line-strong));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filed::before {
|
.filed-name {
|
||||||
content: '▸ ';
|
min-width: 0;
|
||||||
opacity: 0.7;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Noten er et ark, ikke en flade i appen.
|
/* Noten er et ark, ikke en flade i appen.
|
||||||
@@ -1763,17 +1775,6 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The person chip: the project mark's shape in a person's own colour. No ▸
|
|
||||||
before it — the coloured dot already says "belongs to", and two markers on
|
|
||||||
one short chip is noise. It sits next to the title rather than pushed to the
|
|
||||||
right, so in the Delt med mig group it reads as "Ferieplan — Sofie". */
|
|
||||||
.filed.person {
|
|
||||||
margin-left: 0.5em;
|
|
||||||
max-width: 40%;
|
|
||||||
}
|
|
||||||
.filed.person::before {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sharepanel .none {
|
.sharepanel .none {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,9 +1,13 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
<!-- Wunjo, the rune Verdande's name begins with. Drawn as strokes rather than
|
<!-- Wunjo, the rune Verdande's name begins with. Drawn as strokes rather than
|
||||||
as a filled glyph so it stays legible at 16px in a browser tab. -->
|
as a filled glyph so it stays legible at 16px in a browser tab. Bigger and
|
||||||
|
heavier than it was, to read clearly as a home-screen icon, and nudged right
|
||||||
|
of the geometric centre so the heavy stave and the light flag balance about
|
||||||
|
the middle of the tile — a bounding box centred on 32 leaves the stave
|
||||||
|
looking left-hung. -->
|
||||||
<rect width="64" height="64" rx="14" fill="#111312"/>
|
<rect width="64" height="64" rx="14" fill="#111312"/>
|
||||||
<g fill="none" stroke="#5fbf8f" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round">
|
<g fill="none" stroke="#5fbf8f" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 14v36"/>
|
<path d="M26 11v42"/>
|
||||||
<path d="M22 17l17 8-17 8"/>
|
<path d="M26 15l17 9.5L26 34"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 447 B After Width: | Height: | Size: 734 B |