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,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;
|
||||
};
|
||||
Reference in new issue
Block a user