feat: lodge server, client and admin
This commit is contained in:
commit
95fc50e2c2
84 files changed
+7625
No files matched your search
@@ -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