feat: lodge server, client and admin
This commit is contained in:
commit
95fc50e2c2
84 files changed
+7625
No files matched your search
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<meta content="scale" name="text-scale">
|
||||
<link href="%sveltekit.assets%/favicon.svg" rel="icon" type="image/svg+xml">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
// biome-ignore-all lint/suspicious/noEmptySource: temporary
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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.";
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import { ModeWatcher, mode, toggleMode } from "mode-watcher";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { requireSession, session, signOut } from "$lib/session.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const onLogin = $derived(page.url.pathname === "/login");
|
||||
|
||||
onMount(() => {
|
||||
if (!onLogin) {
|
||||
requireSession();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
|
||||
{#if onLogin}
|
||||
{@render children()}
|
||||
{:else if session.signedIn}
|
||||
<header class="bar">
|
||||
<a class="wordmark" href="/">lodge</a>
|
||||
<nav aria-label="Sections">
|
||||
<a aria-current={page.url.pathname === "/" ? "page" : undefined} href="/"
|
||||
>Repositories</a
|
||||
>
|
||||
<a
|
||||
aria-current={page.url.pathname === "/machines" ? "page" : undefined}
|
||||
href="/machines"
|
||||
>Machines</a
|
||||
>
|
||||
</nav>
|
||||
<div class="tools">
|
||||
<button class="plain" onclick={toggleMode} type="button">
|
||||
{mode.current === "dark" ? "Day print" : "Night print"}
|
||||
</button>
|
||||
<button class="plain" onclick={signOut} type="button">Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
height: 3rem;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
font-stretch: 68%;
|
||||
letter-spacing: 0.01em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
text-decoration-color: transparent;
|
||||
}
|
||||
|
||||
nav a[aria-current="page"] {
|
||||
text-decoration-thickness: 2px;
|
||||
text-decoration-color: var(--red);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.plain {
|
||||
padding: 0.25rem 0;
|
||||
color: var(--ink-2);
|
||||
background: none;
|
||||
border: 0;
|
||||
transition: color 150ms var(--ease);
|
||||
}
|
||||
|
||||
.plain:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.bar {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tools {
|
||||
gap: 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
nav a:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import {
|
||||
ago,
|
||||
exact,
|
||||
lithology,
|
||||
type Machine,
|
||||
type Repo,
|
||||
type Revision,
|
||||
} from "$lib/lodge";
|
||||
import { describe } from "$lib/session.svelte";
|
||||
|
||||
const CORE_BEDS = 60;
|
||||
const KEY_LENGTH = 7;
|
||||
|
||||
let repos = $state<Repo[] | null>(null);
|
||||
let machines: string[] = $state([]);
|
||||
let cores: Record<string, Revision[]> = $state({});
|
||||
let problem = $state("");
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [list, known] = await Promise.all([
|
||||
api.get<Repo[]>("/repos"),
|
||||
api.get<Machine[]>("/machines"),
|
||||
]);
|
||||
repos = list;
|
||||
machines = known.map((machine) => machine.name);
|
||||
await Promise.all(
|
||||
list.map(async (repo) => {
|
||||
const revisions = await api.get<Revision[]>(
|
||||
`/repos/${repo.key}/revisions`
|
||||
);
|
||||
cores[repo.key] = revisions.slice(0, CORE_BEDS);
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
problem = describe(error);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
const events = api.events();
|
||||
events.onmessage = load;
|
||||
return () => events.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Repositories · lodge</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>Repositories</h1>
|
||||
|
||||
{#if problem}
|
||||
<p class="problem" role="alert">{problem}</p>
|
||||
{/if}
|
||||
|
||||
{#if repos === null && !problem}
|
||||
<div class="skeleton row-skeleton"></div>
|
||||
<div class="skeleton row-skeleton"></div>
|
||||
{:else if repos?.length === 0}
|
||||
<div class="empty">
|
||||
<p>
|
||||
No repository has been logged yet. Connect a machine, then open any git
|
||||
repository on it: lodge notices the repository on the next checkout, or
|
||||
right away with <span class="mono">lodge add</span>.
|
||||
</p>
|
||||
<a class="act" href="/machines">Connect a machine</a>
|
||||
</div>
|
||||
{:else if repos}
|
||||
<ul>
|
||||
{#each repos as repo (repo.key)}
|
||||
<li>
|
||||
<a href="/r/{repo.key}">
|
||||
<span class="name">{repo.name}</span>
|
||||
<span class="facts">
|
||||
<span class="mono" title={repo.key}
|
||||
>{repo.key.slice(0, KEY_LENGTH)}</span
|
||||
>
|
||||
<span class="stamp">{repo.seq}</span>
|
||||
{repo.seq === 1 ? "revision" : "revisions"}, last
|
||||
<time
|
||||
datetime={new Date(repo.time).toISOString()}
|
||||
title={exact(repo.time)}
|
||||
>{ago(repo.time)}</time
|
||||
>
|
||||
{#if cores[repo.key]?.[0]}
|
||||
by {cores[repo.key][0].machine}
|
||||
{/if}
|
||||
</span>
|
||||
<span aria-hidden="true" class="core">
|
||||
{#each cores[repo.key] ?? [] as rev (rev.seq)}
|
||||
<i
|
||||
class={lithology(rev.machine, machines)}
|
||||
style:flex-grow={Math.min(rev.changes.length, 6)}
|
||||
></i>
|
||||
{/each}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 72rem;
|
||||
padding: 3rem 1.5rem 6rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
li a {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem 2rem;
|
||||
padding: 1.25rem 0 2rem;
|
||||
text-decoration: none;
|
||||
border-top: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
li a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: clamp(2.25rem, 6vw, 4rem);
|
||||
font-weight: 700;
|
||||
font-stretch: 66%;
|
||||
line-height: 1;
|
||||
overflow-wrap: anywhere;
|
||||
transition: color 150ms var(--ease);
|
||||
}
|
||||
|
||||
li a:hover .name {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.facts {
|
||||
align-self: end;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--ink-2);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.facts .stamp {
|
||||
font-size: 1.125rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.core {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
height: 1.75rem;
|
||||
margin-top: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.core i {
|
||||
flex: 1 0 0.5rem;
|
||||
max-width: 6rem;
|
||||
border-right: 1px solid var(--ink);
|
||||
transition: background-color 150ms var(--ease);
|
||||
}
|
||||
|
||||
li a:hover .core i {
|
||||
--l: var(--green);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
justify-items: start;
|
||||
max-width: 38rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.row-skeleton {
|
||||
height: 7rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
main {
|
||||
padding: 2rem 1rem 4rem;
|
||||
}
|
||||
|
||||
li a {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.facts {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/archivo/wdth.css";
|
||||
@import "@fontsource-variable/jetbrains-mono/wght.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--paper: #eef0e6;
|
||||
--paper-2: #e3e7d8;
|
||||
--ink: #15211d;
|
||||
--ink-2: #46544d;
|
||||
--rule: rgb(21 33 29 / 0.2);
|
||||
--grid: rgb(36 105 79 / 0.13);
|
||||
--grid-strong: rgb(36 105 79 / 0.3);
|
||||
--green: #24694f;
|
||||
--green-wash: rgb(36 105 79 / 0.12);
|
||||
--red: #b3341f;
|
||||
--red-wash: rgb(179 52 31 / 0.11);
|
||||
--ochre: #c9a24a;
|
||||
--sans: "Archivo Variable", system-ui, sans-serif;
|
||||
--mono: "JetBrains Mono Variable", ui-monospace, monospace;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--paper: #162026;
|
||||
--paper-2: #1d2a31;
|
||||
--ink: #e6eadc;
|
||||
--ink-2: #a5b2a8;
|
||||
--rule: rgb(230 234 220 / 0.2);
|
||||
--grid: rgb(111 199 163 / 0.09);
|
||||
--grid-strong: rgb(111 199 163 / 0.24);
|
||||
--green: #74caa6;
|
||||
--green-wash: rgb(116 202 166 / 0.14);
|
||||
--red: #f08a74;
|
||||
--red-wash: rgb(240 138 116 / 0.14);
|
||||
--ochre: #d9b45e;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 100%;
|
||||
line-height: 1.5;
|
||||
scrollbar-color: var(--ink-2) transparent;
|
||||
scrollbar-width: thin;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
caret-color: var(--red);
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: var(--paper);
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-decoration-color: var(--rule);
|
||||
text-underline-offset: 0.22em;
|
||||
transition:
|
||||
text-decoration-color 150ms var(--ease),
|
||||
color 150ms var(--ease);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--green);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stamp {
|
||||
font-weight: 600;
|
||||
font-stretch: 68%;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-stretch: 80%;
|
||||
color: var(--ink-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8125em;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.act {
|
||||
padding: 0.125rem 0;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid currentColor;
|
||||
transition:
|
||||
color 150ms var(--ease),
|
||||
background-color 150ms var(--ease);
|
||||
}
|
||||
|
||||
.act:hover:not(:disabled) {
|
||||
background: var(--green-wash);
|
||||
}
|
||||
|
||||
.act.danger {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.act.danger:hover:not(:disabled) {
|
||||
background: var(--red-wash);
|
||||
}
|
||||
|
||||
.act.solid {
|
||||
padding: 0.625rem 1rem;
|
||||
color: var(--paper);
|
||||
background: var(--ink);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.act.solid:hover:not(:disabled) {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.act.solid.danger {
|
||||
color: var(--paper);
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.field::placeholder {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.field:focus-visible {
|
||||
outline: none;
|
||||
border-bottom-color: var(--red);
|
||||
}
|
||||
|
||||
.problem {
|
||||
font-weight: 500;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.lith {
|
||||
--l: var(--ink);
|
||||
position: relative;
|
||||
background-color: var(--paper);
|
||||
}
|
||||
|
||||
.lith::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
background: var(--l);
|
||||
}
|
||||
|
||||
.lith-0::after {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Ccircle cx='3' cy='3' r='0.9' fill='black' stroke='none'/%3E%3C/svg%3E")
|
||||
repeat;
|
||||
}
|
||||
|
||||
.lith-1::after {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M-1 9 9 -1M-1 1 1 -1M7 9 9 7'/%3E%3C/svg%3E")
|
||||
repeat;
|
||||
}
|
||||
|
||||
.lith-2::after {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 2.5h7M6 7.5h6M0 7.5h1'/%3E%3C/svg%3E")
|
||||
repeat;
|
||||
}
|
||||
|
||||
.lith-3::after {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 .5h12M0 4.5h12M.5 0v4M6.5 4v4'/%3E%3C/svg%3E")
|
||||
repeat;
|
||||
}
|
||||
|
||||
.lith-4::after {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M4 2v4M2 4h4'/%3E%3C/svg%3E")
|
||||
repeat;
|
||||
}
|
||||
|
||||
.lith-5::after {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 3q3 -3 6 0t6 0'/%3E%3C/svg%3E")
|
||||
repeat;
|
||||
}
|
||||
|
||||
.lith-6::after {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 5l4 -4l4 4'/%3E%3C/svg%3E")
|
||||
repeat;
|
||||
}
|
||||
|
||||
.lith-web {
|
||||
background-color: var(--ochre);
|
||||
}
|
||||
|
||||
.lith-web::after,
|
||||
.lith-gone::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lith-gone {
|
||||
outline: 1px dashed var(--rule);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
color: transparent;
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
@keyframes settle {
|
||||
from {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 1ms !important;
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { ApiError, api } from "$lib/api/client";
|
||||
import { describe, session } from "$lib/session.svelte";
|
||||
|
||||
let password = $state("");
|
||||
let busy = $state(false);
|
||||
let problem = $state("");
|
||||
|
||||
const submit = async (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
busy = true;
|
||||
problem = "";
|
||||
try {
|
||||
await api.post("/session", { password });
|
||||
session.signedIn = true;
|
||||
await goto("/");
|
||||
} catch (error) {
|
||||
problem =
|
||||
error instanceof ApiError && error.status === 401
|
||||
? "That is not the admin token. Use the ADMIN_TOKEN value from the server's .env file."
|
||||
: describe(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign in · lodge</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<div aria-hidden="true" class="core lith lith-1"></div>
|
||||
<form onsubmit={submit}>
|
||||
<h1>lodge</h1>
|
||||
<p class="lede">
|
||||
Agent notes for every repository, at every depth of their history.
|
||||
</p>
|
||||
<label class="label" for="password">Admin token</label>
|
||||
<input
|
||||
autocomplete="current-password"
|
||||
class="field mono"
|
||||
id="password"
|
||||
required
|
||||
type="password"
|
||||
bind:value={password}
|
||||
>
|
||||
{#if problem}
|
||||
<p class="problem" role="alert">{problem}</p>
|
||||
{/if}
|
||||
<button class="act solid" disabled={busy} type="submit">
|
||||
{busy ? "Opening…" : "Open the log"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(3rem, 12vw, 9rem) minmax(0, 26rem);
|
||||
gap: clamp(1.5rem, 5vw, 4rem);
|
||||
min-height: 100dvh;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.core {
|
||||
--l: var(--green);
|
||||
border-right: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
align-self: center;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(3.5rem, 10vw, 6rem);
|
||||
font-weight: 800;
|
||||
font-stretch: 62%;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 22rem;
|
||||
margin-bottom: 2.5rem;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.act {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,815 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { api } from "$lib/api/client";
|
||||
import Note from "$lib/components/Note.svelte";
|
||||
import StripLog from "$lib/components/StripLog.svelte";
|
||||
import {
|
||||
ago,
|
||||
blob,
|
||||
changeKind,
|
||||
exact,
|
||||
fileId,
|
||||
type Machine,
|
||||
type Repo,
|
||||
type RepoState,
|
||||
type Revision,
|
||||
SHARED,
|
||||
shortSha,
|
||||
spaceName,
|
||||
text,
|
||||
} from "$lib/lodge";
|
||||
import { describe } from "$lib/session.svelte";
|
||||
|
||||
interface Opened {
|
||||
before: string | null;
|
||||
now: string | null;
|
||||
}
|
||||
|
||||
const KEY_LENGTH = 12;
|
||||
const TRACK_TICKS = 100;
|
||||
|
||||
const key = $derived(page.params.key ?? "");
|
||||
const query = $derived(page.url.searchParams);
|
||||
const space = $derived(query.get("space") ?? "");
|
||||
const path = $derived(query.get("path") ?? "");
|
||||
const requestedView = $derived(query.get("view") ?? "read");
|
||||
|
||||
let revisions: Revision[] = $state([]);
|
||||
let machines: string[] = $state([]);
|
||||
let name = $state("");
|
||||
let loaded = $state(false);
|
||||
let problem = $state("");
|
||||
let fresh = $state(0);
|
||||
let repoState = $state<RepoState | null>(null);
|
||||
let opened = $state<Opened | null>(null);
|
||||
let confirming = $state("");
|
||||
let typedName = $state("");
|
||||
let busy = $state(false);
|
||||
|
||||
const states = new Map<number, Promise<RepoState>>();
|
||||
|
||||
const latest = $derived(revisions[0]?.seq ?? 0);
|
||||
const at = $derived(Number(query.get("at")) || latest);
|
||||
const bed = $derived(revisions.find((rev) => rev.seq === at));
|
||||
const markdown = $derived(path.toLowerCase().endsWith(".md"));
|
||||
const view = $derived(
|
||||
!markdown && requestedView === "read" ? "source" : requestedView
|
||||
);
|
||||
|
||||
const history = $derived.by(() => {
|
||||
const byFile = new Map<string, Revision[]>();
|
||||
for (const rev of revisions) {
|
||||
for (const change of rev.changes) {
|
||||
const id = fileId(change.space, change.path);
|
||||
byFile.set(id, [...(byFile.get(id) ?? []), rev]);
|
||||
}
|
||||
}
|
||||
return byFile;
|
||||
});
|
||||
|
||||
const fileHistory = $derived(history.get(fileId(space, path)) ?? []);
|
||||
const touched = $derived(
|
||||
path ? new Set(fileHistory.map((rev) => rev.seq)) : null
|
||||
);
|
||||
const entry = $derived(repoState?.spaces[space]?.[path]);
|
||||
const written = $derived(fileHistory.find((rev) => rev.seq <= at));
|
||||
const writtenChange = $derived(
|
||||
written?.changes.find(
|
||||
(change) => change.space === space && change.path === path
|
||||
)
|
||||
);
|
||||
const surfaceHash = $derived(
|
||||
fileHistory[0]?.changes.find(
|
||||
(change) => change.space === space && change.path === path
|
||||
)?.hash ?? ""
|
||||
);
|
||||
const restorable = $derived(Boolean(entry) && entry?.hash !== surfaceHash);
|
||||
|
||||
const spaces = $derived(
|
||||
Object.keys(repoState?.spaces ?? {}).sort((a, b) => {
|
||||
if (a === SHARED || b === SHARED) {
|
||||
return a === SHARED ? -1 : 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
);
|
||||
|
||||
const href = (patch: Record<string, string | number>) => {
|
||||
const next = new URLSearchParams(query);
|
||||
for (const [param, value] of Object.entries(patch)) {
|
||||
if (value === "" || (param === "at" && value === latest)) {
|
||||
next.delete(param);
|
||||
} else {
|
||||
next.set(param, String(value));
|
||||
}
|
||||
}
|
||||
const search = next.toString();
|
||||
return search ? `?${search}` : page.url.pathname;
|
||||
};
|
||||
|
||||
const select = (seq: number, scrubbing: boolean) => {
|
||||
confirming = "";
|
||||
goto(href({ at: seq }), {
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
replaceState: scrubbing,
|
||||
});
|
||||
};
|
||||
|
||||
const ticks = (space_: string, path_: string) => {
|
||||
const revs = history.get(fileId(space_, path_)) ?? [];
|
||||
const oldest = revisions.at(-1)?.seq ?? 0;
|
||||
const span = Math.max(1, latest - oldest);
|
||||
return revs.map((rev) => ({
|
||||
above: rev.seq > at,
|
||||
left: ((rev.seq - oldest) / span) * TRACK_TICKS,
|
||||
}));
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [revs, repos, known] = await Promise.all([
|
||||
api.get<Revision[]>(`/repos/${key}/revisions`),
|
||||
api.get<Repo[]>("/repos"),
|
||||
api.get<Machine[]>("/machines"),
|
||||
]);
|
||||
revisions = revs;
|
||||
name = repos.find((repo) => repo.key === key)?.name ?? "";
|
||||
machines = known.map((machine) => machine.name);
|
||||
problem = "";
|
||||
} catch (error) {
|
||||
problem = describe(error);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
const stateAt = (seq: number) => {
|
||||
let pending = states.get(seq);
|
||||
if (!pending) {
|
||||
pending = api.get<RepoState>(`/repos/${key}/state?at=${seq}`);
|
||||
states.set(seq, pending);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
const readText = async (hash: string | undefined) =>
|
||||
hash ? text(await blob(hash)) : "";
|
||||
|
||||
$effect(() => {
|
||||
if (!at) {
|
||||
return;
|
||||
}
|
||||
let stale = false;
|
||||
stateAt(at)
|
||||
.then((value) => {
|
||||
if (!stale) {
|
||||
repoState = value;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
states.delete(at);
|
||||
problem = describe(error);
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const hash = entry?.hash;
|
||||
const base = writtenChange?.base;
|
||||
if (!hash) {
|
||||
opened = null;
|
||||
return;
|
||||
}
|
||||
let stale = false;
|
||||
Promise.all([readText(hash), readText(base)])
|
||||
.then(([now, before]) => {
|
||||
if (!stale) {
|
||||
opened = { before, now };
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
problem = describe(error);
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
});
|
||||
|
||||
const restore = async () => {
|
||||
if (!(entry && revisions[0])) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await api.post(`/repos/${key}/revisions`, {
|
||||
changes: [{ base: surfaceHash, hash: entry.hash, path, space }],
|
||||
main_branch: revisions[0].main_branch,
|
||||
main_sha: revisions[0].main_sha,
|
||||
name,
|
||||
});
|
||||
confirming = "";
|
||||
await load();
|
||||
await goto(href({ at: "" }), { noScroll: true });
|
||||
} catch (error) {
|
||||
problem = describe(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
busy = true;
|
||||
try {
|
||||
await api.del(`/repos/${key}`);
|
||||
await goto("/");
|
||||
} catch (error) {
|
||||
problem = describe(error);
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
const events = api.events();
|
||||
events.onmessage = async (message) => {
|
||||
const event = JSON.parse(message.data) as { repo: string; seq: number };
|
||||
if (event.repo === key && event.seq) {
|
||||
await load();
|
||||
fresh = event.seq;
|
||||
}
|
||||
};
|
||||
return () => events.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{path ? `${path.split("/").at(-1)} · ` : ""}{name || "repository"}
|
||||
· lodge
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet slashed(value: string)}
|
||||
{#each value.split("/") as part, index (index)}
|
||||
{index ? "/" : ""}<wbr>{part}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<div class="sheet" class:reading={Boolean(path)}>
|
||||
<aside class="log">
|
||||
<dl class="titleblock">
|
||||
<div class="wide">
|
||||
<dt class="label">Repository</dt>
|
||||
<dd class="name stamp">{name || "…"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="label">Depth</dt>
|
||||
<dd class="stamp">{at} / {latest}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="label">Root</dt>
|
||||
<dd class="mono" title={key}>{key.slice(0, KEY_LENGTH)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if loaded}
|
||||
<StripLog
|
||||
{at}
|
||||
{fresh}
|
||||
{machines}
|
||||
onselect={select}
|
||||
{revisions}
|
||||
{touched}
|
||||
/>
|
||||
{:else}
|
||||
<div class="skeleton column-skeleton"></div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<section aria-label="Files at this depth" class="files">
|
||||
{#if problem}
|
||||
<p class="problem" role="alert">{problem}</p>
|
||||
{/if}
|
||||
|
||||
{#if bed}
|
||||
<div class="bedinfo">
|
||||
<h2 class="stamp">
|
||||
Depth {bed.seq}{bed.seq === latest ? ", the surface" : ""}
|
||||
</h2>
|
||||
<p>
|
||||
<strong>{bed.machine}</strong>
|
||||
<time
|
||||
datetime={new Date(bed.time).toISOString()}
|
||||
title={exact(bed.time)}
|
||||
>{ago(bed.time)}</time
|
||||
>
|
||||
on {bed.main_branch || "detached"}
|
||||
<span class="mono">{shortSha(bed.main_sha)}</span>
|
||||
</p>
|
||||
<ul>
|
||||
{#each bed.changes as change (fileId(change.space, change.path))}
|
||||
<li class={changeKind(change)}>
|
||||
<span class="kind">{changeKind(change)}</span>
|
||||
{#if change.hash}
|
||||
<a
|
||||
href={href({ path: change.path, space: change.space, view: "changes" })}
|
||||
>{@render slashed(change.path)}</a
|
||||
>
|
||||
{:else}
|
||||
<span>{@render slashed(change.path)}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if bed.seq !== latest}
|
||||
<a class="surface" href={href({ at: "" })}>Back to the surface</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each spaces as group (group)}
|
||||
<h3 class="label space">
|
||||
{group === SHARED ? "Shared notes" : `Branch ${spaceName(group)}`}
|
||||
</h3>
|
||||
<ul class="rows">
|
||||
{#each Object.entries(repoState?.spaces[group] ?? {}).sort( ([a], [b]) => a.localeCompare(b) ) as [ file, info ] (file)}
|
||||
<li>
|
||||
<a
|
||||
aria-current={group === space && file === path ? "true" : undefined}
|
||||
href={href({ path: file, space: group, view: "" })}
|
||||
>
|
||||
<span class="file">
|
||||
<span class="dir"
|
||||
>{@render slashed(file.slice(0, file.lastIndexOf("/") + 1))}</span
|
||||
>{file.slice(file.lastIndexOf("/") + 1)}
|
||||
</span>
|
||||
<span aria-hidden="true" class="track">
|
||||
{#each ticks(group, file) as tick (tick.left)}
|
||||
<i style:left="{tick.left}%" class:above={tick.above}></i>
|
||||
{/each}
|
||||
</span>
|
||||
<span class="when">{ago(info.time)}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
{#if loaded && !problem}
|
||||
<p class="empty">
|
||||
{latest
|
||||
? "Nothing had been written yet at this depth. Drag the column up towards the surface."
|
||||
: "This repository has no revisions yet. Write a file under docs/agents on any connected machine and it lands here within seconds."}
|
||||
</p>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<details class="removal">
|
||||
<summary>Delete this repository</summary>
|
||||
<p>
|
||||
Erases all {latest} revisions from the server. Files on your machines
|
||||
stay where they are, and a machine that still syncs this repository
|
||||
starts a new history. Type <strong>{name}</strong> to confirm.
|
||||
</p>
|
||||
<input
|
||||
aria-label="Repository name"
|
||||
class="field"
|
||||
placeholder={name}
|
||||
bind:value={typedName}
|
||||
>
|
||||
<button
|
||||
class="act solid danger"
|
||||
disabled={busy || typedName !== name || !name}
|
||||
onclick={remove}
|
||||
type="button"
|
||||
>
|
||||
Delete with all history
|
||||
</button>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<main class="reader">
|
||||
{#if path}
|
||||
<a class="back" href={href({ path: "", space: "", view: "" })}>Files</a>
|
||||
<h1>{path.split("/").at(-1)}</h1>
|
||||
<p class="meta">
|
||||
{space === SHARED ? "shared" : `branch ${spaceName(space)}`}
|
||||
<span class="mono">{path}</span>
|
||||
{#if written}
|
||||
written at depth {written.seq} by {written.machine},
|
||||
<time
|
||||
datetime={new Date(written.time).toISOString()}
|
||||
title={exact(written.time)}
|
||||
>{ago(written.time)}</time
|
||||
>, on
|
||||
<span class="together"
|
||||
>{written.main_branch || "detached"}
|
||||
<span class="mono">{shortSha(written.main_sha)}</span></span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if entry}
|
||||
<div class="controls">
|
||||
{#each [["read", "Read"], ["changes", "Changes"], ["source", "Source"]] as [ id, title ] (id)}
|
||||
{#if markdown || id !== "read"}
|
||||
<a
|
||||
aria-current={view === id ? "true" : undefined}
|
||||
href={href({ view: id })}
|
||||
>{title}</a
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if restorable}
|
||||
{#if confirming === "restore"}
|
||||
<span class="confirm">
|
||||
Put depth {written?.seq} back on the surface?
|
||||
<button
|
||||
class="act"
|
||||
disabled={busy}
|
||||
onclick={restore}
|
||||
type="button"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
class="act danger"
|
||||
onclick={() => {
|
||||
confirming = "";
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Keep current
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
class="act"
|
||||
onclick={() => {
|
||||
confirming = "restore";
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Restore this version
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if opened === null}
|
||||
<div class="skeleton text-skeleton"></div>
|
||||
{:else if opened.now === null}
|
||||
<p class="empty">
|
||||
This file is binary, so there is nothing to read here. Fetch it with
|
||||
<span class="mono">lodge show {at} {path}</span>.
|
||||
</p>
|
||||
{:else}
|
||||
<Note before={opened.before} {markdown} now={opened.now} {view} />
|
||||
{/if}
|
||||
{:else if repoState}
|
||||
<p class="empty">
|
||||
{path.split("/").at(-1)}
|
||||
does not exist at depth {at}.
|
||||
{#if fileHistory.length}
|
||||
It was last written at depth
|
||||
<a href={href({ at: fileHistory[0].seq })}>{fileHistory[0].seq}</a>.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty hint">
|
||||
Pick a file to pull its core from depth {at}. Drag along the column, or
|
||||
use the arrow keys, to read the notes as they were at any revision.
|
||||
</p>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
display: grid;
|
||||
grid-template-columns: 17.5rem 20rem minmax(0, 1fr);
|
||||
height: calc(100dvh - 3rem);
|
||||
}
|
||||
|
||||
.log {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-right: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.titleblock {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.titleblock div {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.titleblock div + div + div {
|
||||
border-left: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.titleblock .wide {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.column-skeleton {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.files,
|
||||
.reader {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.files {
|
||||
border-right: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.bedinfo {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.bedinfo h2 {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.bedinfo p,
|
||||
.meta {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.bedinfo strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.bedinfo ul {
|
||||
margin-top: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bedinfo li {
|
||||
display: grid;
|
||||
grid-template-columns: 4.25rem minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.125rem 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.kind {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-stretch: 80%;
|
||||
line-height: 1.3125rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.added .kind {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.deleted .kind,
|
||||
.deleted span + span {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.deleted span + span {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.surface {
|
||||
display: inline-block;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.space {
|
||||
padding-bottom: 0.25rem;
|
||||
margin-top: 1.75rem;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.rows a {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.125rem 0.75rem;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 0.375rem;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
transition: background-color 150ms var(--ease);
|
||||
}
|
||||
|
||||
.rows a:hover {
|
||||
color: inherit;
|
||||
background: var(--green-wash);
|
||||
}
|
||||
|
||||
.rows a[aria-current="true"] {
|
||||
color: var(--paper);
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.file {
|
||||
grid-column: 1 / -1;
|
||||
font-weight: 600;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.dir {
|
||||
font-weight: 400;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.when {
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
grid-row: 2;
|
||||
grid-column: 1 / -1;
|
||||
align-self: center;
|
||||
height: 0.4375rem;
|
||||
border-bottom: 1px solid currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.track i {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.track i.above {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty {
|
||||
max-width: 34rem;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 20vh;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.removal {
|
||||
margin-top: 4rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.removal summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.removal[open] {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.removal p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.removal .act {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.reader {
|
||||
padding: 2rem clamp(1.5rem, 4vw, 4rem) 6rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
font-stretch: 75%;
|
||||
line-height: 1.05;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.together {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
align-items: baseline;
|
||||
padding-bottom: 0.5rem;
|
||||
margin: 1.5rem 0 2rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.controls a {
|
||||
text-decoration-color: transparent;
|
||||
}
|
||||
|
||||
.controls a[aria-current="true"] {
|
||||
font-weight: 700;
|
||||
text-decoration-thickness: 2px;
|
||||
text-decoration-color: var(--red);
|
||||
}
|
||||
|
||||
.controls > :is(button, .confirm) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.text-skeleton {
|
||||
max-width: 70ch;
|
||||
height: 16rem;
|
||||
}
|
||||
|
||||
@media (max-width: 72rem) {
|
||||
.sheet {
|
||||
grid-template-columns: 15rem 17rem minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 56rem) {
|
||||
.sheet {
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.titleblock {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.titleblock div:not(.wide) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.titleblock .wide {
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.titleblock .label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.files,
|
||||
.reader {
|
||||
padding: 1.25rem 1rem 5rem;
|
||||
}
|
||||
|
||||
.files {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.sheet.reading .files,
|
||||
.sheet:not(.reading) .reader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user