308 lines
6.8 KiB
Svelte
308 lines
6.8 KiB
Svelte
<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>
|