To ønsker, meldt sammen. --- eksport af det hele --- Eksporten fandtes allerede — konto som JSON, noter som Markdown i en zip, og pr. projekt CSV i Todoist-format og ICS. Hullet var, at opgaver kun kunne hentes ÉT PROJEKT AD GANGEN. Med femten projekter er det femten downloads for at flytte sine egne opgaver, hvilket er en eksport, der teknisk set findes, og som ingen bruger. `GET /export/projects.zip` er én CSV pr. projekt. En zip og ikke én bred fil med en projektkolonne: Todoists egen eksport er en fil pr. projekt, så det er den form, der importeres i den anden ende — og den form, man kan hive ét projekt ud af uden at åbne et regneark. Samme begrundelse som noteeksporten, der er en mappe med Markdown og ikke én lang fil. `GET /export/tasks.ics` er alle daterede opgaver i én kalender. Ét fil og ikke en zip, fordi en kalender er lavet til at rumme mange ting, og projektet følger med hver opgave. Udaterede udelades — en VTODO uden dato er en linje, de fleste læsere taber på gulvet. Prøven ser I filerne frem for på statuskoden: en tom zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og det er præcis den form, en fejl i en eksport har. To projekter, ikke ét, så en løkke der kun eksporterer det sidste ikke består. --- en indsat liste bliver til opgaver --- Quick-add er ét felt på én linje, og browseren smider linjeskiftene væk ved indsætning i et `<input>` — så en kopieret liste blev til én opgave med punkterne mast sammen. Nu læses den. Indrykning bærer niveauet, og bredden læses af teksten frem for at være gættet til to eller fire: en liste fra Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med et andet, og begge skal give ét niveau. `- `, `*`, `1.` og `1)` fjernes som den notation, de er. `- [x]` er derimod indhold: en halvt afkrydset liste, der kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for. Teksten røres ikke ud over mærket. Datoer, `#projekt` og `@etiket` læses af quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at forstå en opgavelinje, ikke to. Derfor tager `/tasks/quick-add` nu et `parent_id`: en indrykket linje skal parses som enhver anden. En undertask lever i sin forælders projekt. Et `#projekt` på en indrykket linje honoreres derfor ikke, og det siges højt som `unknown_project` frem for at ordet bare forsvinder fra titlen. Indsætter man i en opgaves undertask-felt, bliver linjerne undertasks dér. Det er "enten opgaver eller undertasks" afgjort af hvor man indsætter, uden at nogen skal vælge noget i en menu. Listen vises, før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal opdage bagefter — og visningen er også svaret på "forstod den indrykningen", stillet mens der stadig er en Fortryd-knap. --- rettet undervejs --- Rækkens undertask-tæller stod stille, når en undertask blev tilføjet fra ruden: rækken sagde 1/2, mens ruden foran viste fire. Tallene bor på forælderen og regnes ud ved hentning, og serveren sender ikke forælderen igen, fordi et barn blev lavet. Det gjaldt også den gamle vej, ikke kun den nye. Go-suiten grøn. Fuld chromium-suite: 103 bestået, 0 fejlet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
785 lines
20 KiB
Svelte
785 lines
20 KiB
Svelte
<script>
|
||
|
||
/** Import, export and project templates. */
|
||
import { api, humanMessage } from '$lib/api.js';
|
||
import { app } from '$lib/stores.svelte.js';
|
||
import { goto } from '$app/navigation';
|
||
import { ago } from '$lib/when.js';
|
||
import { t, tag } from '$lib/i18n.svelte.js';
|
||
|
||
// --- beaconet -----------------------------------------------------------------
|
||
|
||
// Hvad denne installation melder, og til hvem. Se internal/beacon for hvorfor
|
||
// den er slået til som udgangspunkt frem for at være obligatorisk.
|
||
let beacon = $state(null);
|
||
|
||
$effect(() => {
|
||
if (!app.user?.is_admin) return;
|
||
api.beacon().then((r) => (beacon = r)).catch(() => {});
|
||
});
|
||
|
||
async function saveBeacon(change) {
|
||
try {
|
||
beacon = await api.setBeacon(change);
|
||
} catch (e) {
|
||
app.toast(humanMessage(e));
|
||
// Læst forfra, så en afvist ændring ikke bliver stående som et flueben,
|
||
// der ikke svarer til, hvad serveren gør.
|
||
api.beacon().then((r) => (beacon = r)).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// --- backups ---------------------------------------------------------------------
|
||
|
||
let backups = $state([]);
|
||
let backupsLoaded = $state(false);
|
||
let backingUp = $state(false);
|
||
let lastBackup = $state('');
|
||
|
||
$effect(() => {
|
||
if (!app.user?.is_admin) return;
|
||
api
|
||
.listBackups()
|
||
.then((r) => {
|
||
backups = r.backups;
|
||
backupsLoaded = true;
|
||
})
|
||
.catch((e) => app.toast(humanMessage(e)));
|
||
});
|
||
|
||
/**
|
||
* Takes one now.
|
||
*
|
||
* Worth a button of its own rather than trusting the nightly job: waiting until
|
||
* tonight to find out whether backups work at all is how somebody finds out on
|
||
* the day they need one. It is also the only way to get a snapshot from *before*
|
||
* something you are about to do.
|
||
*/
|
||
async function backupNow() {
|
||
backingUp = true;
|
||
lastBackup = '';
|
||
try {
|
||
const made = await api.runBackup();
|
||
backups = [made, ...backups];
|
||
lastBackup = t('data.backupMade', { size: size(made.size_bytes) });
|
||
} catch (e) {
|
||
app.toast(humanMessage(e));
|
||
} finally {
|
||
backingUp = false;
|
||
}
|
||
}
|
||
|
||
function size(bytes) {
|
||
if (!bytes) return t('data.empty');
|
||
const mb = bytes / 1024 / 1024;
|
||
if (mb >= 1) return `${mb.toFixed(1)} MB`;
|
||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||
}
|
||
|
||
|
||
// --- Todoist import ------------------------------------------------------------
|
||
|
||
let importing = $state(false);
|
||
let importingNotes = $state(false);
|
||
|
||
/**
|
||
* A zip of Markdown files becomes notes.
|
||
*
|
||
* The whole archive in one request rather than a file at a time: the pictures
|
||
* have to be in the same request as the notes that name them, or the links
|
||
* cannot be rewritten.
|
||
*/
|
||
async function importNotes(event) {
|
||
const file = event.currentTarget.files?.[0];
|
||
if (!file) return;
|
||
importingNotes = true;
|
||
try {
|
||
const result = await api.importNotes(file);
|
||
app.toast(
|
||
t('data.notesImported', {
|
||
n: result.created ?? 0,
|
||
files: result.files ?? 0
|
||
})
|
||
);
|
||
} catch (e) {
|
||
app.toast(humanMessage(e));
|
||
} finally {
|
||
importingNotes = false;
|
||
event.target.value = '';
|
||
}
|
||
}
|
||
let result = $state(null);
|
||
|
||
async function importTodoist(event) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
event.target.value = ''; // so the same file can be picked twice after a failure
|
||
|
||
importing = true;
|
||
result = null;
|
||
try {
|
||
result = await api.importTodoist(file);
|
||
await app.refreshProjects();
|
||
} catch (e) {
|
||
app.toast(e.fields?.file ?? humanMessage(e));
|
||
} finally {
|
||
importing = false;
|
||
}
|
||
}
|
||
|
||
// --- templates --------------------------------------------------------------
|
||
|
||
let templates = $state([]);
|
||
let loadingTemplates = $state(true);
|
||
|
||
let saveFrom = $state('');
|
||
let templateName = $state('');
|
||
let savingTemplate = $state(false);
|
||
|
||
let usingId = $state('');
|
||
let newProjectName = $state('');
|
||
let startDate = $state('');
|
||
|
||
$effect(() => {
|
||
api
|
||
.listTemplates()
|
||
.then((r) => (templates = r.templates))
|
||
.catch((e) => app.toast(humanMessage(e)))
|
||
.finally(() => (loadingTemplates = false));
|
||
});
|
||
|
||
let projects = $derived(app.projects.filter((p) => !p.is_inbox));
|
||
|
||
async function saveTemplate(event) {
|
||
event.preventDefault();
|
||
if (!saveFrom) return;
|
||
savingTemplate = true;
|
||
try {
|
||
const created = await api.saveTemplate({
|
||
project_id: saveFrom,
|
||
name: templateName.trim() || app.projectById(saveFrom)?.name || 'Skabelon',
|
||
description: ''
|
||
});
|
||
templates = [created, ...templates];
|
||
saveFrom = '';
|
||
templateName = '';
|
||
} catch (e) {
|
||
app.toast(humanMessage(e));
|
||
} finally {
|
||
savingTemplate = false;
|
||
}
|
||
}
|
||
|
||
async function useTemplate(event) {
|
||
event.preventDefault();
|
||
if (!usingId) return;
|
||
try {
|
||
const project = await api.useTemplate(usingId, {
|
||
name: newProjectName.trim(),
|
||
start_date: startDate
|
||
});
|
||
await app.refreshProjects();
|
||
usingId = '';
|
||
newProjectName = '';
|
||
startDate = '';
|
||
goto(`/projekt/${project.id}`);
|
||
} catch (e) {
|
||
app.toast(e.fields?.start_date ?? humanMessage(e));
|
||
}
|
||
}
|
||
|
||
async function deleteTemplate(template) {
|
||
if (!confirm(t('data.deleteTemplate', { name: template.name }))) return;
|
||
|
||
const previous = templates;
|
||
templates = templates.filter((t) => t.id !== template.id);
|
||
try {
|
||
await api.deleteTemplate(template.id);
|
||
} catch (e) {
|
||
templates = previous;
|
||
app.toast(humanMessage(e));
|
||
}
|
||
}
|
||
|
||
let exportProject = $state('');
|
||
|
||
// --- the trash -------------------------------------------------------------
|
||
|
||
let trashed = $state([]);
|
||
let loadingTrash = $state(true);
|
||
|
||
$effect(() => {
|
||
api
|
||
.listTrashedProjects()
|
||
.then((r) => (trashed = r.projects))
|
||
.catch((e) => app.toast(humanMessage(e)))
|
||
.finally(() => (loadingTrash = false));
|
||
});
|
||
|
||
async function restore(project) {
|
||
const previous = trashed;
|
||
trashed = trashed.filter((p) => p.id !== project.id);
|
||
try {
|
||
await api.restoreProject(project.id);
|
||
await app.refreshProjects();
|
||
} catch (e) {
|
||
trashed = previous;
|
||
app.toast(humanMessage(e));
|
||
}
|
||
}
|
||
|
||
/** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */
|
||
function daysLeft(iso) {
|
||
return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000));
|
||
}
|
||
</script>
|
||
|
||
<section class="panel" id="import">
|
||
<header>
|
||
<h2>{t('data.import')}</h2>
|
||
<p class="hint">
|
||
{t('data.importHint')}
|
||
</p>
|
||
</header>
|
||
|
||
<div class="field">
|
||
<label for="todoist">{t('data.csvFile')}</label>
|
||
<input id="todoist" type="file" accept=".csv,text/csv" onchange={importTodoist} disabled={importing} />
|
||
</div>
|
||
|
||
{#if importing}
|
||
<p class="hint">{t('data.importing')}</p>
|
||
{/if}
|
||
|
||
{#if result}
|
||
<div class="result">
|
||
<p class="hint">
|
||
{result.tasks} opgave{result.tasks === 1 ? '' : 'r'}, {result.sections}
|
||
sektion{result.sections === 1 ? '' : 'er'} og {result.comments}
|
||
kommentar{result.comments === 1 ? '' : 'er'} hentet ind.
|
||
</p>
|
||
{#if result.warnings?.length}
|
||
<!-- Reported rather than silently dropped: a partial import that says
|
||
nothing is discovered weeks later. -->
|
||
<ul class="warnings">
|
||
{#each result.warnings as warning, i (i)}
|
||
<li>{warning}</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{#if result.project_id}
|
||
<div class="row">
|
||
<a class="link" href="/projekt/{result.project_id}">{t('data.openProject')}</a>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
||
to the four that are read every day, and it belongs with the other "where did
|
||
that go" questions rather than above them. -->
|
||
<section class="panel" id="faerdige">
|
||
<header>
|
||
<h2>{t('done.title')}</h2>
|
||
<p class="hint">{t('data.doneHint')}</p>
|
||
</header>
|
||
<div class="row">
|
||
<a class="link" href="/faerdige">{t('data.openDone')}</a>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel" id="papirkurv">
|
||
<header>
|
||
<h2>{t('data.trash')}</h2>
|
||
<p class="hint">
|
||
{t('data.trashHint')}
|
||
</p>
|
||
</header>
|
||
|
||
{#if loadingTrash}
|
||
<p class="empty">…</p>
|
||
{:else if trashed.length === 0}
|
||
<p class="empty">{t('data.trashEmpty')}</p>
|
||
{:else}
|
||
<ul class="list">
|
||
{#each trashed as project (project.id)}
|
||
<li>
|
||
<div class="what">
|
||
<span class="title">{project.name}</span>
|
||
<span class="meta">
|
||
{project.task_count} opgave{project.task_count === 1 ? '' : 'r'} ·
|
||
{daysLeft(project.purge_after)} dage tilbage
|
||
</span>
|
||
</div>
|
||
<button class="use" onclick={() => restore(project)}>{t('data.restore')}</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
|
||
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
||
is a complete copy of the database, so this panel hands out everybody's data. -->
|
||
{#if app.user?.is_admin}
|
||
<section class="panel" id="sikkerhedskopier">
|
||
<header>
|
||
<h2>{t('data.backups')}</h2>
|
||
<p class="hint">
|
||
{t('data.backupsHint')}
|
||
</p>
|
||
<p class="hint">
|
||
{t('data.backupsAttachments')}
|
||
</p>
|
||
<!-- Said here rather than only in the documentation: this is the page where
|
||
somebody downloads the file, and the day the key matters is the day
|
||
they are restoring it somewhere else and wondering why the mailbox is
|
||
silent. -->
|
||
<p class="hint">
|
||
{t('data.backupsSecrets')}
|
||
</p>
|
||
</header>
|
||
|
||
<div class="row">
|
||
<button class="secondary" onclick={backupNow} disabled={backingUp}>
|
||
{backingUp ? t('data.backingUp') : t('data.backupNow')}
|
||
</button>
|
||
{#if lastBackup}<span class="saved">{lastBackup}</span>{/if}
|
||
</div>
|
||
|
||
{#if backups.length}
|
||
<ul class="list">
|
||
{#each backups as backup (backup.id)}
|
||
<li>
|
||
<div class="what">
|
||
<span class="primary-line">
|
||
{ago(backup.started_at)}
|
||
{#if backup.error}<span class="failed">{t('data.backupFailed')}</span>{/if}
|
||
</span>
|
||
<span class="secondary">
|
||
{#if backup.error}
|
||
{backup.error}
|
||
{:else}
|
||
{size(backup.size_bytes)}{#if !backup.present}{' · '}{t('data.swept')}{/if}
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
{#if backup.present && !backup.error}
|
||
<a class="link" href={api.backupURL(backup.id)} download>{t('data.download')}</a>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else if backupsLoaded}
|
||
<p class="empty">{t('data.noBackups')}</p>
|
||
{/if}
|
||
|
||
<p class="hint">
|
||
{t('data.restoreHint')}
|
||
</p>
|
||
</section>
|
||
{/if}
|
||
|
||
<section class="panel" id="eksport">
|
||
<header>
|
||
<h2>{t('data.export')}</h2>
|
||
<p class="hint">
|
||
{t('data.exportHint')}
|
||
</p>
|
||
</header>
|
||
|
||
<div class="row">
|
||
<!-- Plain links, not fetch: the browser's own download handling knows what to
|
||
do with a Content-Disposition, and the session cookie rides along. -->
|
||
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
|
||
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
|
||
<a class="link" href={api.exportAllCSVURL()} download>{t('data.exportAllCSV')}</a>
|
||
<a class="link" href={api.exportAllICSURL()} download>{t('data.exportAllICS')}</a>
|
||
</div>
|
||
|
||
<!-- The way back in, and from anywhere else: a folder of Markdown is what
|
||
Obsidian, Bear and iA Writer all produce, and what the Apple Notes script in
|
||
tools/ writes. Pictures beside the files come with them. -->
|
||
<div class="field">
|
||
<label for="import-notes">{t('data.importNotes')}</label>
|
||
<input
|
||
id="import-notes"
|
||
type="file"
|
||
accept=".zip,application/zip"
|
||
onchange={importNotes}
|
||
disabled={importingNotes}
|
||
/>
|
||
<p class="hint">{t('data.importNotesHint')}</p>
|
||
</div>
|
||
|
||
<!-- Vælgeren bliver stående ved siden af "alle": den er stadig vejen til ét
|
||
projekt, og det er en anden opgave end at flytte hele kontoen. -->
|
||
<div class="field">
|
||
<label for="export-project">{t('data.exportOne')}</label>
|
||
<select id="export-project" bind:value={exportProject}>
|
||
<option value="">{t('data.pickProject')}</option>
|
||
{#each app.projects as project (project.id)}
|
||
<option value={project.id}>{project.name}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
|
||
{#if exportProject}
|
||
<div class="row">
|
||
<a class="link" href={api.exportProjectCSVURL(exportProject)} download>{t('data.csv')}</a>
|
||
<a class="link" href={api.exportProjectICSURL(exportProject)} download>{t('data.ics')}</a>
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="panel" id="skabeloner">
|
||
<header>
|
||
<h2>{t('data.templates')}</h2>
|
||
<p class="hint">
|
||
{t('data.templatesHint')}
|
||
</p>
|
||
</header>
|
||
|
||
<form onsubmit={saveTemplate}>
|
||
<div class="field">
|
||
<label for="save-from">{t('data.saveAsTemplate')}</label>
|
||
<select id="save-from" bind:value={saveFrom}>
|
||
<option value="">{t('data.pickProject')}</option>
|
||
{#each projects as project (project.id)}
|
||
<option value={project.id}>{project.name}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
|
||
{#if saveFrom}
|
||
<div class="field">
|
||
<label for="template-name">{t('data.templateName')}</label>
|
||
<input
|
||
id="template-name"
|
||
bind:value={templateName}
|
||
placeholder={app.projectById(saveFrom)?.name ?? ''}
|
||
/>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<button class="primary" type="submit" disabled={savingTemplate}>{t('data.saveTemplate')}</button>
|
||
</div>
|
||
{/if}
|
||
</form>
|
||
|
||
{#if loadingTemplates}
|
||
<p class="empty">…</p>
|
||
{:else if templates.length === 0}
|
||
<p class="empty">{t('data.noTemplates')}</p>
|
||
{:else}
|
||
<ul class="list">
|
||
{#each templates as template (template.id)}
|
||
<li>
|
||
<div class="what">
|
||
<span class="title">{template.name}</span>
|
||
<span class="meta">
|
||
{template.task_count} opgave{template.task_count === 1 ? '' : 'r'}
|
||
</span>
|
||
</div>
|
||
<button class="use" onclick={() => (usingId = usingId === template.id ? '' : template.id)}>
|
||
{t('data.use')}
|
||
</button>
|
||
<button class="remove" onclick={() => deleteTemplate(template)}>{t('data.delete')}</button>
|
||
</li>
|
||
|
||
{#if usingId === template.id}
|
||
<li class="use-form">
|
||
<form onsubmit={useTemplate}>
|
||
<div class="field">
|
||
<label for="new-name">{t('data.newProjectName')}</label>
|
||
<input id="new-name" bind:value={newProjectName} placeholder={template.name} />
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="start">{t('data.startDate')}</label>
|
||
<input id="start" type="date" bind:value={startDate} />
|
||
<p class="hint">{t('data.startDateHint')}</p>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<button class="primary" type="submit">{t('data.createProject')}</button>
|
||
</div>
|
||
</form>
|
||
</li>
|
||
{/if}
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
|
||
<!-- Beaconet.
|
||
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
||
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
||
dem, ikke gemt væk under en fane, ingen åbner. -->
|
||
<section class="panel" id="beacon">
|
||
<header>
|
||
<h2>{t('beacon.title')}</h2>
|
||
<p class="hint">{t('beacon.hint')}</p>
|
||
</header>
|
||
|
||
{#if beacon}
|
||
<label class="check">
|
||
<input
|
||
type="checkbox"
|
||
checked={beacon.enabled}
|
||
onchange={(e) => saveBeacon({ enabled: e.currentTarget.checked })}
|
||
/>
|
||
<span>{t('beacon.send')}</span>
|
||
</label>
|
||
|
||
<!-- Hele beskeden, ord for ord. Et løfte om telemetri er præcis så meget
|
||
værd som læserens mulighed for at efterprøve det, så den står her frem
|
||
for som en sætning om, hvad der cirka bliver sendt. -->
|
||
<pre class="payload">{t('beacon.exactly')}
|
||
{JSON.stringify({ instance_id: beacon.instance_id, version: beacon.version }, null, 2)}</pre>
|
||
|
||
{#if beacon.last_error}
|
||
<!-- Et beacon, der ikke kan nå sin collector, fejler ellers i fuldstændig
|
||
tavshed: det prøver igen i morgen, og "sidst sendt" bliver bare ved
|
||
med at blive ældre. Ikke en fejl, der skal afbryde nogen — en linje,
|
||
hvor siden ellers påstår, at alt er sendt. -->
|
||
<p class="hint">
|
||
{t('beacon.lastError', {
|
||
when: new Date(beacon.last_error_at).toLocaleString(tag()),
|
||
why: beacon.last_error
|
||
})}
|
||
</p>
|
||
{/if}
|
||
{#if beacon.last_ping_at}
|
||
<p class="hint">{t('beacon.lastPing', { when: new Date(beacon.last_ping_at).toLocaleString(tag()) })}</p>
|
||
{/if}
|
||
|
||
<div class="field">
|
||
<label for="collector">{t('beacon.collector')}</label>
|
||
<input
|
||
id="collector"
|
||
value={beacon.collector_url}
|
||
onchange={(e) => saveBeacon({ collector_url: e.currentTarget.value })}
|
||
/>
|
||
<p class="hint">{t('beacon.collectorHint')}</p>
|
||
</div>
|
||
|
||
<label class="check">
|
||
<input
|
||
type="checkbox"
|
||
checked={beacon.is_collector}
|
||
onchange={(e) => saveBeacon({ is_collector: e.currentTarget.checked })}
|
||
/>
|
||
<span>{t('beacon.beCollector')}</span>
|
||
</label>
|
||
|
||
{#if beacon.is_collector && beacon.stats}
|
||
<p class="hint">{t('beacon.receiving')}</p>
|
||
<div class="counts">
|
||
<div class="count">
|
||
<strong>{beacon.stats.total}</strong>
|
||
<span>{t('beacon.total')}</span>
|
||
</div>
|
||
<div class="count">
|
||
<strong>{beacon.stats.active_7d}</strong>
|
||
<span>{t('beacon.active7')}</span>
|
||
</div>
|
||
<div class="count">
|
||
<strong>{beacon.stats.active_30d}</strong>
|
||
<span>{t('beacon.active30')}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{#if Object.keys(beacon.stats.by_version ?? {}).length}
|
||
<p class="hint">
|
||
{t('beacon.byVersion')}
|
||
{Object.entries(beacon.stats.by_version)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([v, n]) => `${v} ×${n}`)
|
||
.join(', ')}
|
||
</p>
|
||
{/if}
|
||
|
||
<label class="check">
|
||
<input
|
||
type="checkbox"
|
||
checked={beacon.publish_count}
|
||
onchange={(e) => saveBeacon({ publish_count: e.currentTarget.checked })}
|
||
/>
|
||
<span>{t('beacon.publish')}</span>
|
||
</label>
|
||
|
||
{#if beacon.publish_count}
|
||
<div class="field">
|
||
<label for="publishmin">{t('beacon.publishMin')}</label>
|
||
<input
|
||
id="publishmin"
|
||
type="number"
|
||
min="0"
|
||
value={beacon.publish_min}
|
||
onchange={(e) => saveBeacon({ publish_min: Number(e.currentTarget.value) })}
|
||
/>
|
||
<p class="hint">{t('beacon.publishMinHint')}</p>
|
||
</div>
|
||
{/if}
|
||
<p class="hint">{t('beacon.publishHint')}</p>
|
||
{/if}
|
||
{/if}
|
||
</section>
|
||
|
||
<style>
|
||
/* The backup list, which is a list of rows with a right-hand action — the same
|
||
shape the settings pages use elsewhere, said once here because this is the
|
||
only panel on the page that has one. */
|
||
.what {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.primary-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--s2);
|
||
font-size: var(--text-sm);
|
||
}
|
||
|
||
.secondary {
|
||
font-size: var(--text-xs);
|
||
color: var(--ink-faint);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.failed {
|
||
font-size: var(--text-xs);
|
||
font-weight: 560;
|
||
color: var(--danger);
|
||
border: 1px solid var(--danger);
|
||
border-radius: var(--radius-sm);
|
||
padding: 0 var(--s1);
|
||
}
|
||
|
||
form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--s4);
|
||
}
|
||
|
||
.result {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--s2);
|
||
padding: var(--s4);
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.warnings {
|
||
margin: 0;
|
||
padding-left: var(--s5);
|
||
font-size: var(--text-sm);
|
||
color: var(--ink-muted);
|
||
}
|
||
|
||
.what {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.title {
|
||
font-size: var(--text-sm);
|
||
}
|
||
|
||
.meta {
|
||
font-size: var(--text-xs);
|
||
color: var(--ink-faint);
|
||
}
|
||
|
||
.use,
|
||
.remove {
|
||
flex: none;
|
||
font-size: var(--text-xs);
|
||
padding: var(--s1) var(--s2);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--ink-faint);
|
||
transition: color var(--fast) var(--ease);
|
||
}
|
||
|
||
.use:hover {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.remove:hover {
|
||
color: var(--danger);
|
||
background: var(--danger-sunken);
|
||
}
|
||
|
||
.use-form {
|
||
background: var(--surface-sunken);
|
||
}
|
||
|
||
.use-form form {
|
||
width: 100%;
|
||
}
|
||
|
||
.link {
|
||
font-size: var(--text-sm);
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* Beskeden som den sendes. Monospace, fordi den skal kunne sammenlignes tegn
|
||
for tegn med det, der står i dokumentationen. */
|
||
.payload {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
padding: var(--s2) var(--s3);
|
||
overflow-x: auto;
|
||
margin: var(--s2) 0;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.counts {
|
||
display: flex;
|
||
gap: var(--s2);
|
||
margin: var(--s2) 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.count {
|
||
flex: 1 1 7rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: var(--s3);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.count strong {
|
||
font-size: var(--text-xl);
|
||
line-height: 1;
|
||
}
|
||
|
||
.count span {
|
||
font-size: var(--text-xs);
|
||
color: var(--ink-faint);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
</style>
|