2026-08-19 13:07:49 +02:00
|
|
|
<script>
|
|
|
|
|
/**
|
|
|
|
|
* Notes.
|
|
|
|
|
*
|
|
|
|
|
* A list on the left, the note itself on the right. Not a grid of cards: a note
|
|
|
|
|
* is read by its first line far more often than by its whole self, and a list
|
|
|
|
|
* shows twenty of those where a grid shows six.
|
|
|
|
|
*
|
|
|
|
|
* Markdown in a plain textarea for now. The live preview — where the marks show
|
|
|
|
|
* only in the line the cursor is in — is the next piece and the expensive one;
|
|
|
|
|
* this is the shape it will sit inside, and everything else is already true
|
|
|
|
|
* without it.
|
|
|
|
|
*/
|
|
|
|
|
import { api, humanMessage } from '$lib/api.js';
|
|
|
|
|
import { app } from '$lib/stores.svelte.js';
|
2026-08-19 18:59:34 +02:00
|
|
|
import { page } from '$app/stores';
|
2026-08-19 20:46:05 +02:00
|
|
|
import { t, tag } from '$lib/i18n.svelte.js';
|
|
|
|
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
2026-08-19 16:58:53 +02:00
|
|
|
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
2026-08-19 13:07:49 +02:00
|
|
|
|
|
|
|
|
let notes = $state([]);
|
2026-08-19 22:58:19 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Rækkefølgen, og hvorfor den kan vælges.
|
|
|
|
|
*
|
|
|
|
|
* Serveren svarer i én rækkefølge — senest rørt øverst — og det er den rigtige,
|
|
|
|
|
* når man arbejder: den note, man var i gang med, er den, man skal tilbage til.
|
|
|
|
|
* Den er også den eneste, der ikke kan svare på "hvad skrev jeg dengang".
|
|
|
|
|
* Tolv hundrede noter flyttet ind fra et andet program er et arkiv, og et arkiv
|
|
|
|
|
* læses efter, hvornår noget blev skrevet, og efter navn.
|
|
|
|
|
*
|
|
|
|
|
* Sorteret her frem for i en forespørgsel: hele listen er allerede hentet, den
|
|
|
|
|
* fylder ingenting, og et valg, der ikke koster et kald, kan skiftes så hurtigt
|
|
|
|
|
* som man kan ombestemme sig.
|
|
|
|
|
*/
|
|
|
|
|
const ORDERS = [
|
|
|
|
|
{ key: 'updated', label: 'notes.sortUpdated' },
|
|
|
|
|
{ key: 'created', label: 'notes.sortCreated' },
|
|
|
|
|
{ key: 'title', label: 'notes.sortTitle' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let order = $state('updated');
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const saved = localStorage.getItem('notes.order');
|
|
|
|
|
if (saved && ORDERS.some((o) => o.key === saved)) order = saved;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function setOrder(next) {
|
|
|
|
|
order = next;
|
|
|
|
|
localStorage.setItem('notes.order', next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
|
|
|
|
|
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
|
|
|
|
|
let ordered = $derived(
|
|
|
|
|
[...notes].sort((a, b) => {
|
|
|
|
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
|
|
|
if (order === 'title') {
|
|
|
|
|
return (a.title || '').localeCompare(b.title || '', 'da', { sensitivity: 'base' });
|
|
|
|
|
}
|
|
|
|
|
const field = order === 'created' ? 'created_at' : 'updated_at';
|
|
|
|
|
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-08-19 13:07:49 +02:00
|
|
|
let selected = $state(null);
|
|
|
|
|
let query = $state('');
|
|
|
|
|
let status = $state('loading');
|
|
|
|
|
let saving = $state(false);
|
|
|
|
|
|
|
|
|
|
// The text being edited, kept apart from the note it came from so a save that
|
|
|
|
|
// arrives late cannot overwrite what has been typed since.
|
|
|
|
|
let draft = $state('');
|
|
|
|
|
let timer;
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
load(query);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-19 18:59:34 +02:00
|
|
|
// Arriving from somewhere that names a note — a task's panel, a project's page —
|
|
|
|
|
// opens it rather than dropping the person on a list to find it again.
|
|
|
|
|
let asked = $derived($page.url.searchParams.get('note'));
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (!asked || selected?.id === asked) return;
|
|
|
|
|
const found = notes.find((n) => n.id === asked);
|
|
|
|
|
if (found) open(found);
|
|
|
|
|
else api.note(asked).then(open).catch(() => {});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
async function load(q) {
|
|
|
|
|
try {
|
|
|
|
|
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
|
|
|
|
status = 'ready';
|
|
|
|
|
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
status = 'failed';
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function open(note) {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
selected = note;
|
|
|
|
|
draft = note?.body ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 18:23:42 +02:00
|
|
|
/**
|
|
|
|
|
* The first line or so of a note, without its marks.
|
|
|
|
|
*
|
|
|
|
|
* The list showed the Markdown as stored — "# Møde med Anders" and "**fed**" —
|
|
|
|
|
* which is exactly the thing the editor was built to stop showing. What is
|
|
|
|
|
* wanted here is the sentence, not the notation.
|
|
|
|
|
*/
|
|
|
|
|
function plain(body) {
|
|
|
|
|
return (body ?? '')
|
2026-08-20 00:17:50 +02:00
|
|
|
// Et billede har ingen ord i sig. Uden det her stod der
|
|
|
|
|
// "]*\)/g, '')
|
2026-08-19 18:23:42 +02:00
|
|
|
.replace(/^#{1,6}\s+/gm, '')
|
|
|
|
|
.replace(/^\s*[-*+]\s+/gm, '')
|
|
|
|
|
.replace(/^\s*>\s?/gm, '')
|
|
|
|
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
|
|
|
.replace(/~~([^~]+)~~/g, '$1')
|
|
|
|
|
.replace(/<\/?u>/g, '')
|
|
|
|
|
.replace(/`([^`]+)`/g, '$1')
|
|
|
|
|
.replace(/\*([^*]+)\*/g, '$1')
|
|
|
|
|
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
.trim()
|
|
|
|
|
.slice(0, 90);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 20:46:05 +02:00
|
|
|
/**
|
|
|
|
|
* Hvornår, skrevet som man ville sige det.
|
|
|
|
|
*
|
|
|
|
|
* "i går" og et klokkeslæt i dag; en dato, når det er længere siden. En note
|
|
|
|
|
* fra i formiddags og en fra i fjor skal ikke se ens ud i en liste, man skanner.
|
|
|
|
|
*/
|
|
|
|
|
function when(iso) {
|
|
|
|
|
if (!iso) return '';
|
|
|
|
|
const at = new Date(iso);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const sameDay = at.toDateString() === now.toDateString();
|
|
|
|
|
if (sameDay) return at.toLocaleTimeString(tag(), { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
|
|
|
|
|
const yesterday = new Date(now);
|
|
|
|
|
yesterday.setDate(now.getDate() - 1);
|
|
|
|
|
if (at.toDateString() === yesterday.toDateString()) return t('notes.yesterday');
|
|
|
|
|
|
|
|
|
|
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function projectName(id) {
|
|
|
|
|
return app.projects.find((p) => p.id === id)?.name ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Trukket ind på et projekt i sidebjælken. Nyttelasten er notens id; sidebjælken
|
|
|
|
|
// kender allerede formen fra opgaver.
|
|
|
|
|
let dragging = $state(null);
|
|
|
|
|
|
|
|
|
|
function onDragStart(event, note) {
|
|
|
|
|
dragging = note.id;
|
|
|
|
|
startDrag(event, NOTE, note.id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
async function create() {
|
|
|
|
|
try {
|
|
|
|
|
const note = await api.createNote({ body: '' });
|
|
|
|
|
notes = [note, ...notes];
|
|
|
|
|
open(note);
|
|
|
|
|
document.querySelector('.editor textarea')?.focus();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Saved as you stop typing rather than on a button.
|
|
|
|
|
*
|
|
|
|
|
* A note is the one place in this program where an hour of writing can be lost
|
|
|
|
|
* to one mis-click, and a Gem-knap is a button that exists to be forgotten. The
|
|
|
|
|
* delay is long enough that a sentence is one save and not twenty.
|
|
|
|
|
*/
|
|
|
|
|
function typed() {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
timer = setTimeout(save, 700);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function save() {
|
|
|
|
|
if (!selected) return;
|
|
|
|
|
const id = selected.id;
|
|
|
|
|
const body = draft;
|
|
|
|
|
saving = true;
|
|
|
|
|
try {
|
|
|
|
|
const saved = await api.updateNote(id, { body });
|
|
|
|
|
notes = notes.map((n) => (n.id === id ? saved : n));
|
|
|
|
|
// Only if it is still the one on screen: switching notes mid-save must not
|
|
|
|
|
// drag the previous one's title back onto this one.
|
|
|
|
|
if (selected?.id === id) selected = saved;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
saving = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 16:58:53 +02:00
|
|
|
/**
|
|
|
|
|
* Favourites are the pinned ones. The list already puts them first, so marking
|
|
|
|
|
* one is the same act as saying "keep this where I can see it" — one idea, not
|
|
|
|
|
* two that have to be explained apart.
|
|
|
|
|
*/
|
|
|
|
|
async function favourite(note) {
|
|
|
|
|
const pinned = !note.pinned;
|
|
|
|
|
// Moved before the answer comes back: a star that waits for a round trip
|
|
|
|
|
// feels broken on a slow connection, and the cost of being wrong is a star.
|
|
|
|
|
notes = notes.map((n) => (n.id === note.id ? { ...n, pinned } : n));
|
|
|
|
|
try {
|
|
|
|
|
await api.updateNote(note.id, { pinned });
|
|
|
|
|
await load(query);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
notes = notes.map((n) => (n.id === note.id ? { ...n, pinned: !pinned } : n));
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 17:03:28 +02:00
|
|
|
/**
|
|
|
|
|
* Moves the note into a project, which is what sharing it means: everybody who
|
|
|
|
|
* can read the project can read the note, and an editor can change it. Choosing
|
|
|
|
|
* the empty option takes it back out and makes it the author's alone again.
|
|
|
|
|
*/
|
|
|
|
|
async function share(projectId) {
|
|
|
|
|
if (!selected) return;
|
|
|
|
|
const id = selected.id;
|
|
|
|
|
try {
|
|
|
|
|
const saved = await api.updateNote(id, { project_id: projectId });
|
|
|
|
|
notes = notes.map((n) => (n.id === id ? saved : n));
|
|
|
|
|
if (selected?.id === id) selected = saved;
|
|
|
|
|
app.toast(projectId ? t('notes.shared') : t('notes.unshared'));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
async function remove(note) {
|
|
|
|
|
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteNote(note.id);
|
|
|
|
|
notes = notes.filter((n) => n.id !== note.id);
|
|
|
|
|
if (selected?.id === note.id) open(notes[0] ?? null);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 18:48:00 +02:00
|
|
|
/**
|
|
|
|
|
* What a link is called on screen.
|
|
|
|
|
*
|
|
|
|
|
* A project tag is stored folded, because it is a key — but showing the folded
|
|
|
|
|
* key would mean a note that says #garageristeriet is labelled with a spelling
|
|
|
|
|
* that appears nowhere, neither in the note nor on the project. The project's
|
|
|
|
|
* own name is the right answer, and the fold is what makes it findable.
|
|
|
|
|
*/
|
|
|
|
|
function label(link) {
|
|
|
|
|
if (link.kind !== 'project') return link.target_id;
|
|
|
|
|
const project = app.projects.find(
|
|
|
|
|
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
|
|
|
|
|
);
|
|
|
|
|
return '#' + (project?.name ?? link.target_id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
// What the note points at, for the panel under the editor.
|
|
|
|
|
let links = $derived(selected?.links ?? []);
|
2026-08-19 13:38:18 +02:00
|
|
|
|
2026-08-19 16:58:53 +02:00
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
|
|
|
|
|
|
|
|
|
<div class="notes">
|
|
|
|
|
<aside>
|
|
|
|
|
<div class="head">
|
|
|
|
|
<h1>{t('notes.title')}</h1>
|
2026-08-19 22:58:19 +02:00
|
|
|
<div class="tools">
|
|
|
|
|
<!-- En select frem for tre knapper: rækkefølgen vælges sjældent, og tre
|
|
|
|
|
knapper ville tage plads fra listen hver eneste dag for at spare et
|
|
|
|
|
klik en gang imellem. -->
|
|
|
|
|
<select
|
|
|
|
|
class="order"
|
|
|
|
|
value={order}
|
|
|
|
|
onchange={(e) => setOrder(e.currentTarget.value)}
|
|
|
|
|
aria-label={t('notes.sortBy')}
|
|
|
|
|
>
|
|
|
|
|
{#each ORDERS as o}
|
|
|
|
|
<option value={o.key}>{t(o.label)}</option>
|
|
|
|
|
{/each}
|
|
|
|
|
</select>
|
|
|
|
|
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
|
|
|
|
</div>
|
2026-08-19 13:07:49 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
class="search"
|
|
|
|
|
type="search"
|
|
|
|
|
bind:value={query}
|
|
|
|
|
placeholder={t('notes.search')}
|
|
|
|
|
aria-label={t('notes.search')}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{#if status === 'loading'}
|
|
|
|
|
<p class="hint">{t('history.loading')}</p>
|
|
|
|
|
{:else if !notes.length}
|
|
|
|
|
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<ul>
|
2026-08-19 22:58:19 +02:00
|
|
|
{#each ordered as note (note.id)}
|
2026-08-19 13:07:49 +02:00
|
|
|
<li>
|
2026-08-19 16:58:53 +02:00
|
|
|
<div class="rowline">
|
2026-08-19 20:46:05 +02:00
|
|
|
<button
|
|
|
|
|
class="row"
|
|
|
|
|
class:on={selected?.id === note.id}
|
|
|
|
|
onclick={() => open(note)}
|
|
|
|
|
draggable="true"
|
|
|
|
|
ondragstart={(e) => onDragStart(e, note)}
|
|
|
|
|
ondragend={() => (dragging = null)}
|
|
|
|
|
>
|
2026-08-19 16:58:53 +02:00
|
|
|
<strong>{note.title || t('notes.untitled')}</strong>
|
2026-08-19 20:46:05 +02:00
|
|
|
<!-- Dato og begyndelse på samme linje, som Apple Noter gør det: to
|
|
|
|
|
linjer pr. note frem for tre, og datoen er dét, man skanner
|
|
|
|
|
efter, når man leder efter noget, man skrev i tirsdags. -->
|
|
|
|
|
<span class="under">
|
|
|
|
|
<span class="when">{when(note.updated_at)}</span>
|
|
|
|
|
<span class="preview">{plain(note.body)}</span>
|
|
|
|
|
</span>
|
|
|
|
|
{#if note.project_id}
|
|
|
|
|
<span class="filed">{projectName(note.project_id)}</span>
|
|
|
|
|
{/if}
|
2026-08-19 16:58:53 +02:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="star"
|
|
|
|
|
class:on={note.pinned}
|
|
|
|
|
onclick={() => favourite(note)}
|
|
|
|
|
aria-pressed={note.pinned}
|
|
|
|
|
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
|
|
|
|
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
|
|
|
|
>
|
|
|
|
|
{note.pinned ? '★' : '☆'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-08-19 13:07:49 +02:00
|
|
|
</li>
|
|
|
|
|
{/each}
|
|
|
|
|
</ul>
|
|
|
|
|
{/if}
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
<section class="editor">
|
|
|
|
|
{#if selected}
|
2026-08-19 16:58:53 +02:00
|
|
|
<NoteEditor
|
|
|
|
|
note={selected}
|
|
|
|
|
onchange={(body) => {
|
|
|
|
|
draft = body;
|
|
|
|
|
typed();
|
|
|
|
|
}}
|
|
|
|
|
onsave={save}
|
|
|
|
|
/>
|
2026-08-19 13:07:49 +02:00
|
|
|
|
|
|
|
|
<footer>
|
|
|
|
|
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
|
|
|
|
{#if links.length}
|
|
|
|
|
<span class="links">
|
|
|
|
|
{#each links as link}
|
2026-08-19 18:48:00 +02:00
|
|
|
<span class="link">{label(link)}</span>
|
2026-08-19 13:07:49 +02:00
|
|
|
{/each}
|
|
|
|
|
</span>
|
|
|
|
|
{/if}
|
2026-08-19 17:03:28 +02:00
|
|
|
<!-- Sharing a note is filing it in a project.
|
|
|
|
|
Not an access list of its own: projects already have members, roles
|
|
|
|
|
and invitations, all of them tested and all of them things people
|
|
|
|
|
have already learnt here. A second way to give somebody access is a
|
|
|
|
|
second place to get it wrong, and the two would disagree on the day
|
|
|
|
|
one of them was changed. -->
|
|
|
|
|
<label class="share">
|
|
|
|
|
<span class="hint">{t('notes.shareWith')}</span>
|
|
|
|
|
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
|
|
|
|
|
<option value="">{t('notes.private')}</option>
|
|
|
|
|
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
|
|
|
|
<option value={project.id}>{project.name}</option>
|
|
|
|
|
{/each}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
|
2026-08-19 16:58:53 +02:00
|
|
|
<div class="actions">
|
|
|
|
|
<button class="button" onclick={save}>{t('notes.save')}</button>
|
|
|
|
|
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
|
|
|
|
</div>
|
2026-08-19 13:07:49 +02:00
|
|
|
</footer>
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="empty">{t('notes.pickOne')}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.notes {
|
2026-08-19 20:31:10 +02:00
|
|
|
/* En grid-kolonne er som udgangspunkt mindst så bred som sit bredeste
|
|
|
|
|
indhold, og et API-token uden et mellemrum i er ét meget bredt ord. Uden
|
|
|
|
|
min-width: 0 skubber sådan en note hele listen bredere, og editoren
|
|
|
|
|
klemmes sammen — på en note man ikke engang har åben. */
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
2026-08-19 16:58:53 +02:00
|
|
|
/* Air on all sides. The heading, the search box and the list all sat flush
|
|
|
|
|
against the sidebar's rule, which reads as a rendering fault rather than
|
|
|
|
|
as a layout. */
|
2026-08-19 21:24:05 +02:00
|
|
|
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
|
|
|
|
|
ingen kant havde og ingenting kunne støde imod. */
|
|
|
|
|
padding: var(--s3) var(--s4) var(--s3) var(--s4);
|
2026-08-19 13:07:49 +02:00
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: minmax(220px, 300px) 1fr;
|
|
|
|
|
gap: var(--s4);
|
|
|
|
|
height: 100%;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
aside {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
min-height: 0;
|
2026-08-19 20:31:10 +02:00
|
|
|
min-width: 0;
|
2026-08-19 13:07:49 +02:00
|
|
|
border-right: 1px solid var(--line);
|
|
|
|
|
padding-right: var(--s3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.head {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
justify-content: space-between;
|
2026-08-19 22:58:19 +02:00
|
|
|
gap: var(--s2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tools {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--s1);
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.order {
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
border: 0;
|
|
|
|
|
background: none;
|
|
|
|
|
padding: 2px var(--s1);
|
2026-08-20 00:17:50 +02:00
|
|
|
max-width: 13ch;
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h1 {
|
|
|
|
|
font-size: var(--text-xl);
|
2026-08-19 13:33:54 +02:00
|
|
|
line-height: 1.2;
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.new {
|
|
|
|
|
font-size: var(--text-lg);
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
padding: 0 var(--s2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.new:hover {
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 20:46:05 +02:00
|
|
|
/* Tegnet af os, ikke af browseren.
|
|
|
|
|
Safari giver type="search" sit eget udseende — pilleform og forstørrelsesglas
|
|
|
|
|
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
|
|
|
|
|
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
|
|
|
|
|
udgave, og den ser ens ud hele tiden. */
|
2026-08-19 13:07:49 +02:00
|
|
|
.search {
|
|
|
|
|
width: 100%;
|
2026-08-19 20:46:05 +02:00
|
|
|
appearance: none;
|
|
|
|
|
-webkit-appearance: none;
|
|
|
|
|
padding-left: 30px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b918d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-3.5-3.5'/%3E%3C/svg%3E");
|
|
|
|
|
background-repeat: no-repeat;
|
|
|
|
|
background-position: 9px center;
|
|
|
|
|
background-size: 15px 15px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Safaris egen ryd-knap ville sidde oven i vores egen form. */
|
|
|
|
|
.search::-webkit-search-decoration,
|
|
|
|
|
.search::-webkit-search-cancel-button {
|
|
|
|
|
-webkit-appearance: none;
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ul {
|
|
|
|
|
list-style: none;
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 16:58:53 +02:00
|
|
|
.rowline {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--s1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.star {
|
|
|
|
|
flex: none;
|
|
|
|
|
width: 24px;
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.rowline:hover .star,
|
|
|
|
|
.star.on,
|
|
|
|
|
.star:focus-visible {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.star.on {
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
.row {
|
|
|
|
|
display: block;
|
2026-08-19 16:58:53 +02:00
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
2026-08-19 13:07:49 +02:00
|
|
|
text-align: left;
|
2026-08-19 20:46:05 +02:00
|
|
|
padding: var(--s2) var(--s2) var(--s3);
|
2026-08-19 13:07:49 +02:00
|
|
|
border-radius: var(--radius);
|
|
|
|
|
color: var(--ink-muted);
|
2026-08-19 20:46:05 +02:00
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
li:last-child .row {
|
|
|
|
|
border-bottom: 0;
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.row:hover {
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.row.on {
|
|
|
|
|
background: var(--surface-raised);
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 20:46:05 +02:00
|
|
|
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
|
|
|
|
|
slags — noget, der står på en liste og skal læses på et blik — og at give dem
|
|
|
|
|
hver sin størrelse får den ene til at se mindre vigtig ud end den anden. */
|
2026-08-19 13:07:49 +02:00
|
|
|
.row strong {
|
|
|
|
|
display: block;
|
|
|
|
|
font-size: var(--text-sm);
|
2026-08-19 20:46:05 +02:00
|
|
|
font-weight: 600;
|
|
|
|
|
line-height: 1.45;
|
2026-08-19 13:07:49 +02:00
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 20:46:05 +02:00
|
|
|
/* 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. */
|
|
|
|
|
.under {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
min-width: 0;
|
2026-08-19 13:07:49 +02:00
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
color: var(--ink-faint);
|
2026-08-19 20:46:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.when {
|
|
|
|
|
flex: none;
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.preview {
|
|
|
|
|
min-width: 0;
|
2026-08-19 13:07:49 +02:00
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 20:46:05 +02:00
|
|
|
/* Hvor den ligger, med en mappe foran — Apple Noters egen måde at sige det på,
|
|
|
|
|
og den eneste linje her, der ikke er notens eget indhold. */
|
|
|
|
|
.filed {
|
|
|
|
|
display: block;
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filed::before {
|
|
|
|
|
content: '▸ ';
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 21:24:05 +02:00
|
|
|
/* Noten er et ark, ikke en flade i appen.
|
|
|
|
|
*
|
|
|
|
|
* Apple Notes skriver på hvidt, og det er ikke pynt: et ark, der holder op et
|
|
|
|
|
* sted, fortæller hvor teksten hører til, og skiller det, man skriver, fra det
|
|
|
|
|
* program, man skriver det i. Uden det flød noten ud i siden og lignede endnu
|
|
|
|
|
* en rude.
|
|
|
|
|
*
|
|
|
|
|
* `--surface` frem for `#fff`, fordi der er fem temaer. Rollen er "arket løftet
|
|
|
|
|
* fra grunden" — hvid i lyst, cremet i paper, en anelse lysere end grunden i de
|
|
|
|
|
* mørke. Et hårdkodet hvidt ville være et blændende ark i mørkt tema. */
|
2026-08-19 13:07:49 +02:00
|
|
|
.editor {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
min-height: 0;
|
2026-08-19 20:31:10 +02:00
|
|
|
min-width: 0;
|
2026-08-19 21:24:05 +02:00
|
|
|
background: var(--surface);
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
border-radius: var(--radius);
|
2026-08-19 21:28:16 +02:00
|
|
|
/* Luft nok til at teksten ikke rører kanten.
|
|
|
|
|
*
|
|
|
|
|
* Vandret vokser den med ruden: på en bred skærm skal et ark have marginer,
|
|
|
|
|
* ikke bare en kant — clamp holder den mellem en rimelig mindste og en, der
|
|
|
|
|
* ikke bliver til et vindue med tekst i midten. */
|
|
|
|
|
padding: var(--s4) clamp(var(--s4), 3.5%, var(--s6));
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:38:18 +02:00
|
|
|
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
|
|
|
|
set on both at once on purpose: a font-size on one and not the other is a
|
|
|
|
|
whole line of drift by the bottom of a long note. */
|
|
|
|
|
.paper {
|
|
|
|
|
position: relative;
|
2026-08-19 13:07:49 +02:00
|
|
|
flex: 1;
|
|
|
|
|
min-height: 50vh;
|
2026-08-19 13:38:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.paper textarea,
|
|
|
|
|
.paper .mirror {
|
2026-08-19 13:07:49 +02:00
|
|
|
font: inherit;
|
2026-08-19 13:38:18 +02:00
|
|
|
font-size: var(--text-base, 1rem);
|
2026-08-19 13:07:49 +02:00
|
|
|
line-height: 1.6;
|
|
|
|
|
padding: var(--s2) 0;
|
2026-08-19 13:38:18 +02:00
|
|
|
border: 0;
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
overflow-wrap: break-word;
|
|
|
|
|
word-break: normal;
|
|
|
|
|
tab-size: 4;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
resize: none;
|
|
|
|
|
background: none;
|
|
|
|
|
/* Transparent, so what is read is the mirror underneath. The caret keeps its
|
|
|
|
|
own colour or there would be nothing to type against. */
|
|
|
|
|
color: transparent;
|
|
|
|
|
caret-color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea::selection {
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: var(--accent-ink);
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:38:18 +02:00
|
|
|
.mirror {
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Headings change weight and colour, never size: a larger font would take more
|
|
|
|
|
lines than the textarea does and everything below it would slide. */
|
|
|
|
|
.mirror .h1,
|
|
|
|
|
.mirror .h2,
|
|
|
|
|
.mirror .h3,
|
|
|
|
|
.mirror .h4,
|
|
|
|
|
.mirror .h5,
|
|
|
|
|
.mirror .h6 {
|
|
|
|
|
font-weight: 620;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .h1 {
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .quote {
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .bold {
|
|
|
|
|
font-weight: 620;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .italic {
|
|
|
|
|
font-style: italic;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .code {
|
|
|
|
|
font-family: var(--mono, ui-monospace, monospace);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .tag,
|
|
|
|
|
.mirror .wikilink {
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
footer {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
padding-top: var(--s2);
|
|
|
|
|
border-top: 1px solid var(--line);
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.links {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: var(--s1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.link {
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
padding: 0 var(--s1);
|
|
|
|
|
font-family: var(--mono, ui-monospace, monospace);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 17:03:28 +02:00
|
|
|
.share {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--s1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.share select {
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
padding: 2px var(--s1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 16:58:53 +02:00
|
|
|
/* Real buttons. They were bare words, which on a Mac reads as a web page rather
|
|
|
|
|
than as something you can press. */
|
|
|
|
|
.actions {
|
2026-08-19 13:07:49 +02:00
|
|
|
margin-left: auto;
|
2026-08-19 16:58:53 +02:00
|
|
|
display: flex;
|
|
|
|
|
gap: var(--s2);
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-19 16:58:53 +02:00
|
|
|
.button {
|
|
|
|
|
border: 1px solid var(--line-strong);
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
padding: var(--s1) var(--s3);
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.button:hover {
|
|
|
|
|
background: var(--surface-raised);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.button.danger {
|
2026-08-19 13:07:49 +02:00
|
|
|
color: var(--danger);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 16:58:53 +02:00
|
|
|
.button.danger:hover {
|
|
|
|
|
border-color: var(--danger);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
.empty,
|
|
|
|
|
.hint {
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 700px) {
|
|
|
|
|
.notes {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
aside {
|
|
|
|
|
border-right: 0;
|
|
|
|
|
padding-right: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|