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

+54
View File
@@ -0,0 +1,54 @@
import { env } from "$env/dynamic/public";
const BASE = env.PUBLIC_API_BASE_URL ?? "/api";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
type Json = Record<string, unknown> | unknown[];
const send = async (
method: string,
path: string,
body?: Json
): Promise<Response> => {
const init: RequestInit = { credentials: "include", method };
if (body !== undefined) {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(body);
}
const res = await fetch(BASE + path, init);
if (!res.ok) {
const detail = (await res.text()).trim();
throw new ApiError(res.status, detail || res.statusText);
}
return res;
};
const request = async <T>(
method: string,
path: string,
body?: Json
): Promise<T> => {
const res = await send(method, path, body);
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
};
export const api = {
bytes: async (path: string) =>
new Uint8Array(await (await send("GET", path)).arrayBuffer()),
del: <T>(path: string) => request<T>("DELETE", path),
events: () => new EventSource(`${BASE}/events`, { withCredentials: true }),
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body?: Json) => request<T>("POST", path, body),
};
+253
View File
@@ -0,0 +1,253 @@
<script lang="ts">
import { diffLines } from "diff";
import DOMPurify from "dompurify";
import { marked } from "marked";
interface Props {
before: string | null;
markdown: boolean;
now: string;
view: string;
}
interface Row {
kind: "added" | "gap" | "removed" | "same";
text: string;
}
const { before, markdown, now, view }: Props = $props();
const CONTEXT = 3;
const TRAILING_NEWLINE = /\n$/;
const html = $derived(
markdown && view === "read"
? DOMPurify.sanitize(marked.parse(now, { async: false }))
: ""
);
const lines = (value: string) =>
value.replace(TRAILING_NEWLINE, "").split("\n");
const rows = $derived.by(() => {
const result: Row[] = [];
for (const part of diffLines(before ?? "", now)) {
const partLines = lines(part.value);
if (part.added || part.removed) {
const kind = part.added ? "added" : "removed";
result.push(...partLines.map((text): Row => ({ kind, text })));
continue;
}
if (partLines.length <= CONTEXT * 2 + 1) {
result.push(...partLines.map((text): Row => ({ kind: "same", text })));
continue;
}
const hidden = partLines.length - CONTEXT * 2;
result.push(
...partLines
.slice(0, CONTEXT)
.map((text): Row => ({ kind: "same", text })),
{ kind: "gap", text: `${hidden} unchanged lines` },
...partLines
.slice(-CONTEXT)
.map((text): Row => ({ kind: "same", text }))
);
}
return result;
});
</script>
{#if view === "changes"}
<ol class="diff mono">
{#each rows as row, index (index)}
<li class={row.kind}>
{#if row.kind === "removed"}
<del>{row.text || " "}</del>
{:else if row.kind === "added"}
<ins>{row.text || " "}</ins>
{:else}
{row.text || " "}
{/if}
</li>
{/each}
</ol>
{:else if html}
<article class="prose">
{@html html}
</article>
{:else}
<pre class="source mono">{now}</pre>
{/if}
<style>
.prose {
max-width: 70ch;
font-size: 1.0625rem;
line-height: 1.65;
overflow-wrap: break-word;
}
.prose :global(:is(h1, h2, h3, h4)) {
margin: 2.25em 0 0.6em;
font-weight: 700;
font-stretch: 85%;
line-height: 1.2;
text-wrap: balance;
}
.prose :global(h1) {
font-size: 1.625rem;
}
.prose :global(h2) {
font-size: 1.3125rem;
}
.prose :global(h3) {
font-size: 1.125rem;
}
.prose :global(:first-child) {
margin-top: 0;
}
.prose :global(:is(p, ul, ol, pre, blockquote, table)) {
margin: 0 0 1.1em;
}
.prose :global(:is(ul, ol)) {
padding-left: 1.4em;
}
.prose :global(ul) {
list-style: square;
}
.prose :global(ol) {
list-style: decimal;
}
.prose :global(li) {
margin: 0.25em 0;
}
.prose :global(li::marker) {
color: var(--green);
}
.prose :global(code) {
padding: 0.1em 0.3em;
font-family: var(--mono);
font-size: 0.8125em;
background: var(--paper-2);
}
.prose :global(pre) {
padding: 0.875rem 1rem;
overflow-x: auto;
line-height: 1.55;
background: var(--paper-2);
}
.prose :global(pre code) {
padding: 0;
background: none;
}
.prose :global(blockquote) {
position: relative;
padding-left: 1.25em;
color: var(--ink-2);
}
.prose :global(blockquote::before) {
position: absolute;
inset: 0.2em auto 0.2em 0;
width: 1px;
content: "";
background: var(--ochre);
}
.prose :global(hr) {
margin: 2em 0;
border: 0;
border-top: 1px solid var(--rule);
}
.prose :global(table) {
display: block;
overflow-x: auto;
font-variant-numeric: tabular-nums;
border-collapse: collapse;
}
.prose :global(:is(th, td)) {
padding: 0.4em 1em 0.4em 0;
text-align: left;
border-bottom: 1px solid var(--rule);
}
.prose :global(img) {
max-width: 100%;
}
.source,
.diff {
overflow-x: auto;
line-height: 1.6;
tab-size: 2;
white-space: pre;
}
.diff li {
position: relative;
min-width: max-content;
padding: 0 0.75rem 0 1.75rem;
}
.diff .same {
color: var(--ink-2);
}
.diff .added {
background: var(--green-wash);
}
.diff .removed {
background: var(--red-wash);
}
.diff .added::before,
.diff .removed::before {
position: absolute;
left: 0.5rem;
font-weight: 700;
}
.diff .added::before {
color: var(--green);
content: "+";
}
.diff .removed::before {
color: var(--red);
content: "−";
}
.diff ins {
text-decoration: none;
}
.diff del {
text-decoration-color: var(--red);
}
.diff .gap {
padding-block: 0.25rem;
margin: 0.5rem 0;
font-family: var(--sans);
font-size: 0.8125rem;
color: var(--ink-2);
border-block: 1px dashed var(--rule);
}
</style>
+288
View File
@@ -0,0 +1,288 @@
<script lang="ts">
import { ago, lithology, type Revision, shortSha } from "$lib/lodge";
interface Props {
at: number;
fresh: number;
machines: string[];
onselect: (seq: number, scrubbing: boolean) => void;
revisions: Revision[];
touched: Set<number> | null;
}
const { at, fresh, machines, onselect, revisions, touched }: Props = $props();
const BASE_HEIGHT = 20;
const CHANGE_HEIGHT = 6;
const MAX_CHANGES = 6;
const NUMERAL_EVERY = 5;
let column = $state<HTMLElement | undefined>();
let scrubbing = $state(false);
const height = (rev: Revision) =>
BASE_HEIGHT + Math.min(rev.changes.length, MAX_CHANGES) * CHANGE_HEIGHT;
const numbered = (rev: Revision, index: number) =>
rev.seq === at ||
rev.seq % NUMERAL_EVERY === 0 ||
index === 0 ||
index === revisions.length - 1;
const formation = (rev: Revision, index: number) =>
index === 0 || revisions[index - 1].main_sha !== rev.main_sha;
const describeBed = (rev: Revision) =>
`Revision ${rev.seq}, ${rev.machine}, ${rev.changes.length} changed, ${rev.main_branch || "detached"} ${shortSha(rev.main_sha)}, ${ago(rev.time)}`;
const seqAt = (x: number, y: number): number | null => {
const bed = document.elementFromPoint(x, y)?.closest("[data-seq]");
return bed instanceof HTMLElement ? Number(bed.dataset.seq) : null;
};
const press = (event: PointerEvent) => {
const seq = seqAt(event.clientX, event.clientY);
if (seq === null || event.button !== 0) {
return;
}
if (event.pointerType === "mouse" || seq === at) {
scrubbing = true;
column?.setPointerCapture(event.pointerId);
onselect(seq, true);
}
};
const drag = (event: PointerEvent) => {
if (!(scrubbing && column)) {
return;
}
const bounds = column.getBoundingClientRect();
const seq = seqAt(bounds.left + bounds.width / 2, event.clientY);
if (seq !== null && seq !== at) {
onselect(seq, true);
}
};
const release = () => {
if (scrubbing) {
scrubbing = false;
onselect(at, false);
}
};
const step = (event: KeyboardEvent) => {
const move = { ArrowDown: 1, ArrowUp: -1, j: 1, k: -1 }[event.key];
if (!move) {
return;
}
event.preventDefault();
const index = revisions.findIndex((rev) => rev.seq === at) + move;
const next = revisions[Math.max(0, Math.min(revisions.length - 1, index))];
onselect(next.seq, false);
column?.querySelector<HTMLElement>(`[data-seq="${next.seq}"]`)?.focus();
};
$effect(() => {
column
?.querySelector(`[data-seq="${at}"]`)
?.scrollIntoView({ block: "nearest" });
});
</script>
<div
aria-label="Revision history, newest at the top"
aria-orientation="vertical"
class="column"
onkeydown={step}
onpointercancel={release}
onpointerdown={press}
onpointermove={drag}
onpointerup={release}
role="listbox"
tabindex="-1"
bind:this={column}
class:scrubbing
>
{#each revisions as rev, index (rev.seq)}
<button
aria-label={describeBed(rev)}
aria-selected={rev.seq === at}
class="bed"
data-seq={rev.seq}
onclick={() => onselect(rev.seq, false)}
role="option"
title="{rev.machine} · {ago(rev.time)}"
type="button"
style:height="{height(rev)}px"
class:above={rev.seq > at}
class:fresh={rev.seq === fresh}
class:quiet={touched !== null && !touched.has(rev.seq)}
>
<span class="depth stamp">{numbered(rev, index) ? rev.seq : ""}</span>
<span class="fill {lithology(rev.machine, machines)}"></span>
<span class="mark"></span>
<span class="formation">
{#if formation(rev, index)}
<span class="branch">{rev.main_branch || "detached"}</span>
<span class="mono">{shortSha(rev.main_sha)}</span>
{/if}
</span>
</button>
{/each}
</div>
<style>
.column {
display: flex;
flex-direction: column;
padding-bottom: 3rem;
overflow-y: auto;
overscroll-behavior: contain;
user-select: none;
scrollbar-width: none;
background-color: var(--paper-2);
background-image:
linear-gradient(var(--grid-strong) 1px, transparent 1px),
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size:
100% 40px,
100% 8px,
8px 100%;
}
.column.scrubbing {
cursor: grabbing;
}
.bed {
position: relative;
display: grid;
flex: none;
grid-template-columns: 2.75rem 4.5rem 0.5rem 1fr;
align-items: stretch;
padding: 0;
color: var(--ink);
text-align: left;
touch-action: pan-y;
background: none;
border: 0;
border-top: 1px solid var(--rule);
}
.bed.fresh {
animation: settle 600ms var(--ease);
}
.depth {
padding-right: 0.5rem;
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--ink-2);
text-align: right;
}
.fill {
border-inline: 1px solid var(--ink);
transition: opacity 150ms var(--ease);
}
.bed.quiet .fill {
--l: var(--ink-2);
}
.bed.quiet .fill::after {
opacity: 0.6;
}
.bed:not(.quiet) .mark {
background: var(--green);
}
.column:not(:has(.quiet)) .mark {
background: none;
}
.bed.above .fill::after {
display: none;
}
.formation {
display: flex;
gap: 0.375rem;
min-width: 0;
padding-left: 0.375rem;
font-size: 0.75rem;
line-height: 1.25rem;
color: var(--ink-2);
white-space: nowrap;
}
.branch {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
font-stretch: 80%;
color: var(--ink);
}
.bed:hover .fill {
--l: var(--green);
}
.bed[aria-selected="true"] {
touch-action: none;
}
.bed[aria-selected="true"]::before {
position: absolute;
inset: -1px 0 auto;
z-index: 1;
height: 3px;
content: "";
background: var(--red);
}
.bed[aria-selected="true"]::after {
position: absolute;
top: -1.375rem;
right: 0;
z-index: 3;
width: 0.625rem;
height: 2.75rem;
content: "";
background: var(--red);
}
.bed:first-child[aria-selected="true"]::after {
top: 0;
}
.bed:first-child[aria-selected="true"]::before {
top: 0;
}
.bed[aria-selected="true"] .depth {
color: var(--red);
}
.bed:focus-visible {
z-index: 2;
outline-offset: -2px;
}
@media (max-width: 56rem) {
.bed {
grid-template-columns: 1.75rem 1fr 0.25rem;
}
.formation {
display: none;
}
.depth {
padding-right: 0.25rem;
font-size: 0.6875rem;
}
}
</style>
+2
View File
@@ -0,0 +1,2 @@
// place files you want to import through the `$lib` alias in this folder.
// biome-ignore-all lint/suspicious/noEmptySource: temporary
+136
View File
@@ -0,0 +1,136 @@
import { api } from "$lib/api/client";
export interface Change {
base: string;
hash: string;
path: string;
space: string;
}
export interface Revision {
changes: Change[];
machine: string;
main_branch: string;
main_sha: string;
seq: number;
time: number;
}
export interface Repo {
key: string;
name: string;
seq: number;
time: number;
}
export interface Machine {
created_at: number;
id: number;
last_push: number;
name: string;
revisions: number;
}
export interface FileEntry {
hash: string;
time: number;
}
export interface RepoState {
seq: number;
spaces: Record<string, Record<string, FileEntry>>;
}
export interface Code {
code: string;
command: string;
expires_at: number;
}
export const SHARED = "shared";
const PATTERNS = 7;
const WEB = "web";
const REVOKED = "revoked";
const SHORT_SHA = 7;
const BRANCH_PREFIX = "branch:";
export const lithology = (machine: string, machines: string[]): string => {
if (machine === WEB) {
return "lith lith-web";
}
const index = machines.indexOf(machine);
if (machine === REVOKED || index < 0) {
return "lith lith-gone";
}
return `lith lith-${index % PATTERNS}`;
};
export const shortSha = (sha: string): string =>
sha ? sha.slice(0, SHORT_SHA) : "no commit";
export const spaceName = (space: string): string =>
space.startsWith(BRANCH_PREFIX) ? space.slice(BRANCH_PREFIX.length) : space;
export const fileId = (space: string, path: string): string =>
`${space}\n${path}`;
export const changeKind = (
change: Change
): "added" | "deleted" | "modified" => {
if (!change.base) {
return "added";
}
return change.hash ? "modified" : "deleted";
};
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31_536_000_000],
["month", 2_592_000_000],
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
];
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const absolute = new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
});
export const ago = (time: number): string => {
if (!time) {
return "never";
}
const delta = time - Date.now();
for (const [unit, size] of UNITS) {
if (Math.abs(delta) >= size) {
return relative.format(Math.round(delta / size), unit);
}
}
return "just now";
};
export const exact = (time: number): string => absolute.format(time);
const blobs = new Map<string, Promise<Uint8Array>>();
export const blob = (hash: string): Promise<Uint8Array> => {
let pending = blobs.get(hash);
if (!pending) {
pending = api.bytes(`/blobs/${hash}`);
blobs.set(hash, pending);
}
return pending;
};
const decoder = new TextDecoder("utf-8", { fatal: true });
export const text = (bytes: Uint8Array): string | null => {
if (bytes.includes(0)) {
return null;
}
try {
return decoder.decode(bytes);
} catch {
return null;
}
};
+32
View File
@@ -0,0 +1,32 @@
import { goto } from "$app/navigation";
import { ApiError, api } from "$lib/api/client";
export const session = $state({ signedIn: false });
export const requireSession = async (): Promise<void> => {
try {
await api.get("/session");
session.signedIn = true;
} catch {
session.signedIn = false;
await goto("/login");
}
};
export const signOut = async (): Promise<void> => {
await api.del("/session");
session.signedIn = false;
await goto("/login");
};
export const describe = (error: unknown): string => {
if (error instanceof ApiError && error.status === 401) {
session.signedIn = false;
goto("/login");
return "Your session ended. Sign in again.";
}
if (error instanceof ApiError) {
return error.message;
}
return "The server did not answer. Check that lodge-server is running and try again.";
};
+15
View File
@@ -0,0 +1,15 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChildren<T> = T extends { children?: any }
? Omit<T, "children">
: T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};