feat: lodge server, client and admin

This commit is contained in:
hh committed 2026-09-19 23:33:24 +02:00
commit 95fc50e2c2
84 files changed
+7625

No files matched your search

+307
View File
@@ -0,0 +1,307 @@
<script lang="ts">
import { onMount } from "svelte";
import { api } from "$lib/api/client";
import { ago, type Code, exact, lithology, type Machine } from "$lib/lodge";
import { describe } from "$lib/session.svelte";
const SECOND = 1000;
const MINUTE = 60;
let machines = $state<Machine[] | null>(null);
let problem = $state("");
let code = $state<Code | null>(null);
let now = $state(Date.now());
let copied = $state(false);
let revoking = $state(0);
let busy = $state(false);
const names = $derived(machines?.map((machine) => machine.name) ?? []);
const remaining = $derived(
code ? Math.max(0, Math.round((code.expires_at - now) / SECOND)) : 0
);
const countdown = $derived(
`${Math.floor(remaining / MINUTE)}:${String(remaining % MINUTE).padStart(2, "0")}`
);
const load = async () => {
try {
machines = await api.get<Machine[]>("/machines");
} catch (error) {
problem = describe(error);
}
};
const invite = async () => {
busy = true;
copied = false;
try {
code = await api.post<Code>("/codes");
problem = "";
} catch (error) {
problem = describe(error);
} finally {
busy = false;
}
};
const copy = async () => {
if (code) {
await navigator.clipboard.writeText(code.command);
copied = true;
}
};
const revoke = async (id: number) => {
busy = true;
try {
await api.del(`/machines/${id}`);
revoking = 0;
await load();
} catch (error) {
problem = describe(error);
} finally {
busy = false;
}
};
onMount(() => {
load();
const timer = setInterval(() => {
now = Date.now();
if (code && remaining === 0) {
code = null;
load();
}
}, SECOND);
const events = api.events();
events.onmessage = load;
return () => {
clearInterval(timer);
events.close();
};
});
</script>
<svelte:head>
<title>Machines · lodge</title>
</svelte:head>
<main>
<h1>Machines</h1>
<p class="lede">
The legend of the log. Each machine owns a hatch, and every bed it writes is
drawn with it.
</p>
{#if problem}
<p class="problem" role="alert">{problem}</p>
{/if}
<section class="invite">
{#if code && remaining > 0}
<p>
Run this on the new machine. It installs the client, signs in and starts
the daemon. The link works once and expires in
<span class="stamp">{countdown}</span>.
</p>
<pre class="mono command">{code.command}</pre>
<div class="row">
<button class="act solid" onclick={copy} type="button">
{copied ? "Copied" : "Copy command"}
</button>
<button class="act" disabled={busy} onclick={invite} type="button">
New link
</button>
</div>
{:else}
<button class="act solid" disabled={busy} onclick={invite} type="button">
Connect a machine
</button>
{/if}
</section>
{#if machines === null && !problem}
<div class="skeleton row-skeleton"></div>
{:else if machines?.length === 0}
<p class="empty">
No machine is connected yet. The command above works on macOS and Linux,
on Intel and ARM.
</p>
{:else if machines}
<ul>
{#each machines as machine (machine.id)}
<li>
<span
aria-hidden="true"
class="swatch {lithology(machine.name, names)}"
></span>
<span class="name stamp">{machine.name}</span>
<span class="facts">
<span class="stamp">{machine.revisions}</span>
{machine.revisions === 1 ? "revision" : "revisions"}, last push
{ago(machine.last_push)}. Connected
<time
datetime={new Date(machine.created_at).toISOString()}
title={exact(machine.created_at)}
>{ago(machine.created_at)}</time
>.
</span>
<span class="action">
{#if revoking === machine.id}
<span class="confirm">
Its token stops working at once; files on it stay.
<button
class="act danger"
disabled={busy}
onclick={() => revoke(machine.id)}
type="button"
>
Revoke {machine.name}
</button>
<button
class="act"
onclick={() => {
revoking = 0;
}}
type="button"
>
Keep
</button>
</span>
{:else}
<button
class="act danger"
onclick={() => {
revoking = machine.id;
}}
type="button"
>
Revoke
</button>
{/if}
</span>
</li>
{/each}
<li>
<span aria-hidden="true" class="swatch lith lith-web"></span>
<span class="name stamp">web</span>
<span class="facts">Versions restored from this page.</span>
</li>
</ul>
{/if}
</main>
<style>
main {
max-width: 72rem;
padding: 3rem 1.5rem 6rem;
margin: 0 auto;
}
h1 {
font-size: clamp(2.25rem, 6vw, 4rem);
font-weight: 700;
font-stretch: 66%;
line-height: 1;
}
.lede {
max-width: 34rem;
margin-top: 0.75rem;
color: var(--ink-2);
}
.invite {
display: grid;
gap: 1rem;
justify-items: start;
max-width: 46rem;
margin: 2.5rem 0 3.5rem;
}
.command {
max-width: 100%;
padding: 1rem 1.25rem;
overflow-x: auto;
user-select: all;
background: var(--paper-2);
border-left: 1px solid var(--ink);
}
.row {
display: flex;
gap: 1.5rem;
align-items: baseline;
}
li {
display: grid;
grid-template-columns: 6rem minmax(8rem, 14rem) minmax(0, 1fr) auto;
gap: 0.25rem 1.5rem;
align-items: center;
padding: 1rem 0;
border-top: 1px solid var(--ink);
}
.swatch {
height: 3rem;
border: 1px solid var(--ink);
}
.name {
font-size: 1.75rem;
overflow-wrap: anywhere;
}
.facts {
font-size: 0.9375rem;
color: var(--ink-2);
}
.facts .stamp {
color: var(--ink);
}
.confirm {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
justify-content: flex-end;
max-width: 22rem;
font-size: 0.875rem;
}
.empty {
max-width: 34rem;
color: var(--ink-2);
}
.row-skeleton {
height: 5rem;
}
@media (max-width: 48rem) {
main {
padding: 2rem 1rem 4rem;
}
li {
grid-template-columns: 4rem minmax(0, 1fr);
}
.swatch {
grid-row: span 3;
height: 100%;
min-height: 4rem;
}
.facts,
.action {
grid-column: 2;
}
.confirm {
justify-content: flex-start;
}
}
</style>