maarcadetweet: initial commit
AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { createPost, type Post } from "../api/client";
|
||||
|
||||
const MAX = 160;
|
||||
let { onPosted }: { onPosted?: () => void } = $props();
|
||||
let text: string = $state("");
|
||||
let isPosting: boolean = $state(false);
|
||||
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null);
|
||||
|
||||
let remaining = $derived(MAX - text.length);
|
||||
let counterClass = $derived(
|
||||
remaining < 0 ? "counter counter--err" :
|
||||
remaining < 40 ? "counter counter--warn" : "counter"
|
||||
);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
post();
|
||||
}
|
||||
}
|
||||
|
||||
async function post() {
|
||||
if (!text.trim() || remaining < 0 || isPosting) return;
|
||||
isPosting = true;
|
||||
status = { kind: "info", msg: "> posting…" };
|
||||
try {
|
||||
const r: Post = await createPost(text);
|
||||
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` };
|
||||
text = "";
|
||||
onPosted?.();
|
||||
} catch (e) {
|
||||
status = { kind: "err", msg: `> ${String(e)}` };
|
||||
} finally {
|
||||
isPosting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="compose">
|
||||
<div class="compose__head">
|
||||
<span class="title">// compose</span>
|
||||
<span class="handle">@you</span>
|
||||
<span class={counterClass}>{remaining}</span>
|
||||
</div>
|
||||
<div class="compose__body">
|
||||
<span class="prompt">$</span>
|
||||
<textarea
|
||||
bind:value={text}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="// what's happening in 160 chars?"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="compose__foot">
|
||||
<span class="hint">⌘↵ to post</span>
|
||||
<div class="actions">
|
||||
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button>
|
||||
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}>
|
||||
{isPosting ? "posting…" : "post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if status}
|
||||
<div class="status status--{status.kind}">{status.msg}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.compose {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
margin: var(--s-4) var(--s-5);
|
||||
}
|
||||
.compose__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
background: var(--bg-deep);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.title { color: var(--orange); }
|
||||
.handle { color: var(--text-dim); flex: 1; }
|
||||
.counter { color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.counter--warn { color: var(--orange); }
|
||||
.counter--err { color: var(--red); letter-spacing: 0.05em; }
|
||||
.compose__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
}
|
||||
.prompt {
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1.7;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
textarea::placeholder { color: var(--text-dim); }
|
||||
.compose__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.hint { font-family: var(--font-mono); font-size: var(--fs-50); color: var(--text-dim); }
|
||||
.actions { display: flex; gap: var(--s-2); }
|
||||
.btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
|
||||
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.status {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.status--ok { color: var(--green); }
|
||||
.status--err { color: var(--red); }
|
||||
.status--info { color: var(--orange); }
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import type { EmbedExternal } from "../api/client";
|
||||
|
||||
let { external }: { external: EmbedExternal } = $props();
|
||||
|
||||
function hostname(uri: string): string {
|
||||
try {
|
||||
return new URL(uri).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="embed-external"
|
||||
href={external.uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<div class="embed-external__body">
|
||||
<div class="embed-external__title">{external.title || external.uri}</div>
|
||||
{#if external.description}
|
||||
<div class="embed-external__desc">{external.description}</div>
|
||||
{/if}
|
||||
<div class="embed-external__host">{hostname(external.uri)}</div>
|
||||
</div>
|
||||
{#if external.thumb}
|
||||
<div class="embed-external__thumb" aria-hidden="true">
|
||||
<span>thumb</span>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.embed-external {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--s-3);
|
||||
margin: var(--s-2) 0 var(--s-3);
|
||||
padding: var(--s-3);
|
||||
border: 1px solid var(--line-2);
|
||||
border-left: 3px solid var(--orange);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-elev);
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: border-color var(--dur) var(--ease);
|
||||
max-width: 520px;
|
||||
}
|
||||
.embed-external:hover { border-color: var(--orange); }
|
||||
.embed-external__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.embed-external__title {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-100);
|
||||
line-height: var(--lh-snug);
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.embed-external__desc {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
line-height: var(--lh-body);
|
||||
word-wrap: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.embed-external__host {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-top: var(--s-1);
|
||||
}
|
||||
.embed-external__thumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
background: var(--bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { fetchBlob, releaseBlob } from "../api/client";
|
||||
|
||||
// The shape of a single image in an `app.bsky.embed.images` record.
|
||||
// We keep it loose because the AppView passes `embed` through as
|
||||
// a JSON blob, not a typed struct.
|
||||
type BlobRef = {
|
||||
$type?: "blob";
|
||||
ref?: { $link?: string };
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type EmbedImageProps = {
|
||||
image: {
|
||||
alt?: string;
|
||||
image?: BlobRef | unknown;
|
||||
aspectRatio?: { width: number; height: number };
|
||||
};
|
||||
/**
|
||||
* DID of the post's author — needed to fetch the blob from the
|
||||
* right PDS. Required for image embeds, optional for record
|
||||
* embeds where we render an icon only.
|
||||
*/
|
||||
did?: string;
|
||||
};
|
||||
|
||||
let { image, did }: EmbedImageProps = $props();
|
||||
|
||||
const blobRef = $derived((image?.image ?? null) as BlobRef | null);
|
||||
const cid = $derived(blobRef?.ref?.$link ?? null);
|
||||
|
||||
// Lifecycle of an image fetch:
|
||||
// * `loading=true` → show a skeleton at the aspect-ratio frame.
|
||||
// * success → render `<img>` with the cached object URL.
|
||||
// * error → render the alt text in the existing
|
||||
// striped placeholder so the user still sees
|
||||
// something (and screen readers get the alt).
|
||||
//
|
||||
// The object URL is cached in `client.ts` so re-renders of the
|
||||
// same CID (e.g. when scrolling the same image back into view)
|
||||
// reuse the same URL instead of allocating a fresh one each time.
|
||||
let objectUrl: string | null = $state(null);
|
||||
let loading: boolean = $state(false);
|
||||
let errored: boolean = $state(false);
|
||||
let errorMsg: string = $state("");
|
||||
|
||||
// Track the cid we last fetched so we know when to release the
|
||||
// URL back to the cache on a cid change.
|
||||
let currentCid: string | null = null;
|
||||
|
||||
async function loadImage(didStr: string, c: string) {
|
||||
loading = true;
|
||||
errored = false;
|
||||
errorMsg = "";
|
||||
try {
|
||||
const url = await fetchBlob(didStr, c);
|
||||
objectUrl = url;
|
||||
currentCid = c;
|
||||
} catch (e) {
|
||||
errored = true;
|
||||
errorMsg = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (cid && did) {
|
||||
loadImage(did, cid);
|
||||
} else {
|
||||
// No cid (or no did) — fall back to alt-text placeholder.
|
||||
errored = !image?.alt;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Release the cached URL when this component goes away.
|
||||
// For a long-running timeline this keeps the in-memory
|
||||
// cache bounded to the visible images. The browser will
|
||||
// free the underlying blob either way when the WebView
|
||||
// navigates; this is just hygiene.
|
||||
if (currentCid) {
|
||||
releaseBlob(currentCid);
|
||||
currentCid = null;
|
||||
}
|
||||
objectUrl = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<figure class="embed-image">
|
||||
<div
|
||||
class="embed-image__frame"
|
||||
class:embed-image__frame--err={errored}
|
||||
class:embed-image__frame--loading={loading}
|
||||
style={image.aspectRatio
|
||||
? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height};`
|
||||
: ""}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="embed-image__skel" aria-busy="true" aria-live="polite">
|
||||
<span class="embed-image__skel-bar"></span>
|
||||
<span class="embed-image__skel-bar embed-image__skel-bar--short"></span>
|
||||
</div>
|
||||
{:else if objectUrl}
|
||||
<img
|
||||
class="embed-image__img"
|
||||
src={objectUrl}
|
||||
alt={image.alt ?? ""}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<span class="embed-image__alt" title={image.alt ?? ""}>
|
||||
{image.alt || (errored ? "image unavailable" : "image")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if image.alt || errored}
|
||||
<figcaption class="embed-image__caption">
|
||||
{errored
|
||||
? `couldn't load image: ${errorMsg}`
|
||||
: `alt: ${image.alt}`}
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.embed-image {
|
||||
margin: var(--s-2) 0 var(--s-3);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-elev);
|
||||
max-width: 480px;
|
||||
}
|
||||
.embed-image__frame {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--bg-elev),
|
||||
var(--bg-elev) 10px,
|
||||
var(--bg) 10px,
|
||||
var(--bg) 20px
|
||||
);
|
||||
}
|
||||
.embed-image__frame--loading {
|
||||
background: var(--bg-elev);
|
||||
animation: img-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.embed-image__frame--err {
|
||||
background: var(--bg);
|
||||
}
|
||||
.embed-image__skel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
padding: var(--s-3);
|
||||
width: 100%;
|
||||
}
|
||||
.embed-image__skel-bar {
|
||||
height: 6px;
|
||||
border-radius: var(--r-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-elev) 0%,
|
||||
var(--line-2) 50%,
|
||||
var(--bg-elev) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: img-shimmer 1.4s ease-in-out infinite;
|
||||
width: 80%;
|
||||
}
|
||||
.embed-image__skel-bar--short { width: 40%; }
|
||||
.embed-image__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.embed-image__alt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
padding: var(--s-3);
|
||||
text-align: center;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.embed-image__caption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
@keyframes img-pulse {
|
||||
0%, 100% { opacity: 0.95; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
@keyframes img-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.embed-image__frame--loading,
|
||||
.embed-image__skel-bar { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { session, describeServer, type Session } from "../api/client";
|
||||
|
||||
let { onLogin }: { onLogin: (s: Session) => void } = $props();
|
||||
|
||||
let mode: "login" | "register" = $state("register");
|
||||
let handle: string = $state("");
|
||||
let password: string = $state("");
|
||||
let busy = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let serverInfo: any = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
serverInfo = await describeServer();
|
||||
} catch (e) {
|
||||
serverInfo = { error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!handle.trim() || !password) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const s = mode === "register"
|
||||
? await session.register(handle, password)
|
||||
: await session.login(handle, password);
|
||||
onLogin(s);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login">
|
||||
<div class="terminal-head">
|
||||
<div class="dots"><i></i><i></i><i></i></div>
|
||||
<div class="t">maarcadetweet — {mode}</div>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<div class="line">
|
||||
<span class="prompt">$</span> maarcadetweet {mode}
|
||||
</div>
|
||||
<div class="line muted">// the timeline that fits in 160 chars.</div>
|
||||
<div class="line"> </div>
|
||||
{#if serverInfo}
|
||||
<div class="line muted">// pds: {serverInfo.did ?? "?"}</div>
|
||||
<div class="line muted">// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</div>
|
||||
{/if}
|
||||
<div class="line"> </div>
|
||||
<div class="form">
|
||||
<label>
|
||||
<span class="key">handle:</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={handle}
|
||||
placeholder="alice.maarcadetweet.local"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="key">password:</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="≥ 8 chars"
|
||||
disabled={busy}
|
||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="line err">error: {error}</div>
|
||||
{/if}
|
||||
<div class="line"> </div>
|
||||
<div class="line">
|
||||
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
|
||||
{busy ? "..." : mode === "register" ? "create account" : "login"}
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
|
||||
{mode === "register" ? "have an account? login" : "no account? register"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
width: min(560px, 92vw);
|
||||
}
|
||||
.terminal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dots i {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--line-2);
|
||||
display: block;
|
||||
}
|
||||
.dots i:first-child { background: #4a4a4a; }
|
||||
.t {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-inline: auto;
|
||||
}
|
||||
.terminal-body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.85;
|
||||
padding: var(--s-5);
|
||||
}
|
||||
.line { white-space: pre-wrap; }
|
||||
.muted { color: var(--text-dim); }
|
||||
.prompt { color: var(--orange); }
|
||||
.err { color: var(--red); }
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
margin: var(--s-4) 0;
|
||||
}
|
||||
.form label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.key { color: var(--orange); width: 90px; flex-shrink: 0; }
|
||||
.form input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
}
|
||||
.form input:focus { border-color: var(--orange); }
|
||||
.btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
margin-right: var(--s-2);
|
||||
}
|
||||
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
|
||||
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
|
||||
.btn--ghost { background: transparent; color: var(--text-dim); border-color: var(--line-2); }
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
|
||||
let { current = $bindable<View>("home") }: { current: View } = $props();
|
||||
|
||||
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
|
||||
{ id: "home", label: "home", key: "g h", icon: "home" },
|
||||
{ id: "compose", label: "compose", key: "c", icon: "compose" },
|
||||
{ id: "profile", label: "profile", key: "p", icon: "profile" },
|
||||
{ id: "search", label: "search", key: "/", icon: "search" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<nav class="rail" aria-label="navigation">
|
||||
{#each items as item}
|
||||
<button
|
||||
class="rail__btn"
|
||||
class:active={current === item.id}
|
||||
onclick={() => (current = item.id)}
|
||||
title={`${item.label} (${item.key})`}
|
||||
aria-current={current === item.id ? "page" : undefined}
|
||||
>
|
||||
<span class="icon">
|
||||
{#if item.icon === "home"}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
{:else if item.icon === "compose"}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">>_</text>
|
||||
</svg>
|
||||
{:else if item.icon === "profile"}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.rail {
|
||||
width: 88px;
|
||||
background: var(--bg);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-3) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail__btn {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-3) var(--s-2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||
}
|
||||
.rail__btn:hover, .rail__btn:focus-visible {
|
||||
color: var(--text);
|
||||
}
|
||||
.rail__btn.active {
|
||||
color: var(--orange);
|
||||
border-bottom-color: var(--orange);
|
||||
}
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.icon :global(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.label { letter-spacing: 0.04em; }
|
||||
</style>
|
||||
@@ -0,0 +1,496 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
fetchPost,
|
||||
likePost,
|
||||
unlikePost,
|
||||
repostPost,
|
||||
unrepostPost,
|
||||
session,
|
||||
showError,
|
||||
type Post,
|
||||
} from "../api/client";
|
||||
import EmbedImage from "./EmbedImage.svelte";
|
||||
import EmbedExternal from "./EmbedExternal.svelte";
|
||||
import { localStorageKey, useLocalStorage } from "../utils/localstorage";
|
||||
|
||||
type Props = { post: Post; on_thread_click?: (uri: string) => void };
|
||||
let { post, on_thread_click }: Props = $props();
|
||||
|
||||
// Quoted-post cache. When the post's embed is a `record`, we fetch
|
||||
// it once on mount and cache it keyed by URI so navigating
|
||||
// timeline → profile doesn't re-fetch the same quote.
|
||||
let quoted: Post | null = $state(null);
|
||||
let quotedErr: string | null = $state(null);
|
||||
let quotedLoading: boolean = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia")
|
||||
? post.embed?.record
|
||||
: null;
|
||||
if (rec?.uri && !quoted && !quotedLoading) {
|
||||
quotedLoading = true;
|
||||
fetchPost(rec.uri)
|
||||
.then((r) => {
|
||||
quoted = r.post;
|
||||
})
|
||||
.catch((e) => {
|
||||
quotedErr = String(e);
|
||||
})
|
||||
.finally(() => {
|
||||
quotedLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the embed shape once at render time. We sniff $type to
|
||||
// decide which sub-component to mount; an unrecognised $type still
|
||||
// renders the post body, just without any embed.
|
||||
const embedKind = $derived.by(() => {
|
||||
if (!post.embed) return "none";
|
||||
switch (post.embed.$type) {
|
||||
case "app.bsky.embed.images": return "images";
|
||||
case "app.bsky.embed.external": return "external";
|
||||
case "app.bsky.embed.record": return "record";
|
||||
case "app.bsky.embed.recordWithMedia": return "recordWithMedia";
|
||||
default: return "unknown";
|
||||
}
|
||||
});
|
||||
|
||||
const replyParentHandle = $derived(post.parent_uri ? post.handle : "");
|
||||
const isReply = $derived(!!post.parent_uri);
|
||||
const isInThread = $derived(
|
||||
!!post.parent_uri &&
|
||||
!!post.root_uri &&
|
||||
post.parent_uri !== post.root_uri
|
||||
);
|
||||
|
||||
// -- engagement (like / repost) ----------------------------------------
|
||||
//
|
||||
// Counts come from the AppView's `GET /api/post/{uri}` response
|
||||
// (`like_count` / `repost_count`). The home timeline doesn't
|
||||
// return them yet, so the buttons fall back to 0. A future change
|
||||
// can pipe the counts into the timeline feed.
|
||||
//
|
||||
// The "active" state (did I like this?) is local: we don't have
|
||||
// `viewer_liked` on the wire. We track it optimistically — the
|
||||
// flag flips the moment the user clicks, and reverts on a server
|
||||
// error.
|
||||
//
|
||||
// Phase 6b: persist the optimistic state (both `liked` and the
|
||||
// backend's `likedUri`) through a reload. Without persistence the
|
||||
// user would re-login and find every like reverted — which reads
|
||||
// to them as "everything got unliked while you were away". We
|
||||
// store under a `${did}:${rkey}` key so different posts don't
|
||||
// stomp each other. The `useLocalStorage` helper is invoked
|
||||
// inside `$effect.pre` so the key changes track changes to
|
||||
// `post.did` / `post.rkey`.
|
||||
let liked: boolean = $state(false);
|
||||
let likedUri: string | null = $state(null);
|
||||
let reposts: boolean = $state(false);
|
||||
let repostUri: string | null = $state(null);
|
||||
let likeBusy: boolean = $state(false);
|
||||
let repostBusy: boolean = $state(false);
|
||||
|
||||
// Hydrate + persist the like state. The box is bound inside an
|
||||
// `$effect.pre` so the key updates whenever `post.did` /
|
||||
// `post.rkey` change (e.g. navigating from one timeline card to
|
||||
// the next).
|
||||
let likedBox: ReturnType<typeof useLocalStorage<{ liked: boolean; uri: string | null }>> | null =
|
||||
$state(null);
|
||||
$effect.pre(() => {
|
||||
const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
|
||||
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, {
|
||||
liked: false,
|
||||
uri: null,
|
||||
});
|
||||
const stored = likedBox.get();
|
||||
liked = stored.liked;
|
||||
likedUri = stored.uri;
|
||||
});
|
||||
$effect(() => {
|
||||
if (!likedBox) return;
|
||||
likedBox.set({ liked, uri: likedUri });
|
||||
});
|
||||
|
||||
// Pull the latest like/repost counts whenever the post changes.
|
||||
// We don't request `thread` data — the simple shape is enough.
|
||||
// Skip the fetch when the post is null (shouldn't happen for a
|
||||
// card on screen, but harmless).
|
||||
$effect(() => {
|
||||
if (!post.uri) return;
|
||||
fetchPost(post.uri)
|
||||
.then((r) => {
|
||||
// The fetchPost response includes the counts when the post
|
||||
// is found. We read them off and seed the local counter.
|
||||
if (r.like_count != null) likeCount = r.like_count;
|
||||
if (r.repost_count != null) repostCount = r.repost_count;
|
||||
})
|
||||
.catch(() => {
|
||||
// Network or AppView outage — keep whatever we had. The
|
||||
// user can still click the button; counts will resolve
|
||||
// next time the post is rehydrated.
|
||||
});
|
||||
});
|
||||
|
||||
let likeCount: number = $state(0);
|
||||
let repostCount: number = $state(0);
|
||||
|
||||
// Whether the buttons are interactive. We disable them when the
|
||||
// user isn't logged in — anonymous users can read the timeline
|
||||
// but not engage.
|
||||
let authed: boolean = $state(false);
|
||||
$effect(() => {
|
||||
const u = $session;
|
||||
authed = !!u;
|
||||
});
|
||||
|
||||
async function onLikeClick() {
|
||||
if (!authed || likeBusy) return;
|
||||
if (!post.uri || !post.cid) return;
|
||||
likeBusy = true;
|
||||
// Optimistic flip.
|
||||
const wasLiked = liked;
|
||||
const prevCount = likeCount;
|
||||
liked = !wasLiked;
|
||||
likeCount = Math.max(0, likeCount + (wasLiked ? -1 : 1));
|
||||
try {
|
||||
if (wasLiked) {
|
||||
if (!likedUri) {
|
||||
// We have no record of the like URI (e.g. the user
|
||||
// reloaded the page mid-state). Roll back and tell them.
|
||||
liked = wasLiked;
|
||||
likeCount = prevCount;
|
||||
showError("can't unlike: missing like URI");
|
||||
return;
|
||||
}
|
||||
await unlikePost(likedUri);
|
||||
likedUri = null;
|
||||
} else {
|
||||
const r = await likePost(post.uri, post.cid);
|
||||
likedUri = r.uri;
|
||||
}
|
||||
} catch (e) {
|
||||
// Roll back on any failure — the user can retry.
|
||||
liked = wasLiked;
|
||||
likeCount = prevCount;
|
||||
showError(`like failed: ${e}`);
|
||||
} finally {
|
||||
likeBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRepostClick() {
|
||||
if (!authed || repostBusy) return;
|
||||
if (!post.uri || !post.cid) return;
|
||||
repostBusy = true;
|
||||
const wasReposted = reposts;
|
||||
const prevCount = repostCount;
|
||||
reposts = !wasReposted;
|
||||
repostCount = Math.max(0, repostCount + (wasReposted ? -1 : 1));
|
||||
try {
|
||||
if (wasReposted) {
|
||||
if (!repostUri) {
|
||||
reposts = wasReposted;
|
||||
repostCount = prevCount;
|
||||
showError("can't unrepost: missing repost URI");
|
||||
return;
|
||||
}
|
||||
await unrepostPost(repostUri);
|
||||
repostUri = null;
|
||||
} else {
|
||||
const r = await repostPost(post.uri, post.cid);
|
||||
repostUri = r.uri;
|
||||
}
|
||||
} catch (e) {
|
||||
reposts = wasReposted;
|
||||
repostCount = prevCount;
|
||||
showError(`repost failed: ${e}`);
|
||||
} finally {
|
||||
repostBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shortCid(c: string) {
|
||||
return c.length > 12 ? c.slice(0, 6) + "…" + c.slice(-4) : c;
|
||||
}
|
||||
function shortHandle(h: string) {
|
||||
if (!h) return "unknown";
|
||||
return h.length > 22 ? h.slice(0, 18) + "…" : h;
|
||||
}
|
||||
function shortDid(d: string) {
|
||||
return d.length > 22 ? d.slice(0, 14) + "…" + d.slice(-4) : d;
|
||||
}
|
||||
function timeAgo(iso: string) {
|
||||
try {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||||
return `${Math.floor(s / 86400)}d`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
function handleThreadClick() {
|
||||
if (post.root_uri && on_thread_click) {
|
||||
on_thread_click(post.root_uri);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="post">
|
||||
{#if isReply || isInThread}
|
||||
<div class="thread-ctx">
|
||||
{#if isInThread}
|
||||
<button
|
||||
class="thread-ctx__link"
|
||||
type="button"
|
||||
onclick={handleThreadClick}
|
||||
title={`open thread root: ${post.root_uri}`}
|
||||
>🧵 thread</button>
|
||||
<span class="thread-ctx__sep">·</span>
|
||||
{/if}
|
||||
{#if isReply && post.parent_uri}
|
||||
<span class="thread-ctx__reply">
|
||||
↩ in reply to
|
||||
<a class="thread-ctx__handle" href={`/profile/${replyParentHandle}`}>
|
||||
@{shortHandle(replyParentHandle)}
|
||||
</a>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<header class="post__head">
|
||||
<span class="prompt">></span>
|
||||
<a class="handle" href={`/profile/${post.handle}`}>@{shortHandle(post.handle)}</a>
|
||||
<span class="time">{timeAgo(post.created_at)}</span>
|
||||
<span class="cid" title={post.cid}>cid: {shortCid(post.cid)}</span>
|
||||
<span class="did" title={post.did}>{shortDid(post.did)}</span>
|
||||
</header>
|
||||
|
||||
<p class="post__body">{post.text}</p>
|
||||
|
||||
{#if embedKind === "images" && post.embed?.images}
|
||||
<div class="embed-grid">
|
||||
{#each post.embed.images as img, i (i)}
|
||||
<EmbedImage image={img} did={post.did} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if embedKind === "external" && post.embed?.external}
|
||||
<EmbedExternal external={post.embed.external} />
|
||||
{:else if embedKind === "record" || embedKind === "recordWithMedia"}
|
||||
{#if post.embed?.record}
|
||||
<blockquote class="quote">
|
||||
<div class="quote__head">
|
||||
<span class="quote__label">quoted</span>
|
||||
<span class="quote__uri">{post.embed.record.uri}</span>
|
||||
</div>
|
||||
{#if quotedLoading}
|
||||
<div class="quote__loading">loading…</div>
|
||||
{:else if quoted}
|
||||
<p class="quote__body">{quoted.text}</p>
|
||||
<div class="quote__meta">
|
||||
<a class="quote__handle" href={`/profile/${quoted.handle}`}>@{shortHandle(quoted.handle)}</a>
|
||||
<span class="quote__time">{timeAgo(quoted.created_at)}</span>
|
||||
</div>
|
||||
{:else if quotedErr}
|
||||
<div class="quote__err">couldn't fetch quoted post: {quotedErr}</div>
|
||||
{/if}
|
||||
</blockquote>
|
||||
{/if}
|
||||
{#if embedKind === "recordWithMedia" && post.embed?.media}
|
||||
{#if post.embed.media.images}
|
||||
<div class="embed-grid">
|
||||
{#each post.embed.media.images as img, i (i)}
|
||||
<EmbedImage image={img} did={post.did} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if post.embed.media.external}
|
||||
<EmbedExternal external={post.embed.media.external} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<footer class="post__foot">
|
||||
<span class="dot">·</span>
|
||||
<span class="ago">{timeAgo(post.created_at)}</span>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
class="action"
|
||||
class:action--active={liked}
|
||||
type="button"
|
||||
onclick={onLikeClick}
|
||||
disabled={!authed || likeBusy}
|
||||
title={!authed ? "log in to like" : liked ? "unlike" : "like"}
|
||||
>
|
||||
<span class="action__icon">{liked ? "♥" : "♡"}</span>
|
||||
<span class="action__count">{likeCount}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action"
|
||||
class:action--active={reposts}
|
||||
type="button"
|
||||
onclick={onRepostClick}
|
||||
disabled={!authed || repostBusy}
|
||||
title={!authed ? "log in to repost" : reposts ? "unrepost" : "repost"}
|
||||
>
|
||||
<span class="action__icon">{reposts ? "⇆" : "↻"}</span>
|
||||
<span class="action__count">{repostCount}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.post {
|
||||
padding: var(--s-4) var(--s-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: background var(--dur) var(--ease);
|
||||
}
|
||||
.post:hover { background: rgba(255, 102, 0, 0.02); }
|
||||
.thread-ctx {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--s-2);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
}
|
||||
.thread-ctx__link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--orange);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
.thread-ctx__link:hover { text-decoration: underline; }
|
||||
.thread-ctx__sep { color: var(--line-2); }
|
||||
.thread-ctx__reply { color: var(--text-dim); }
|
||||
.thread-ctx__handle { color: var(--text); }
|
||||
.thread-ctx__handle:hover { color: var(--orange); }
|
||||
.post__head {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
margin-bottom: var(--s-2);
|
||||
}
|
||||
.prompt { color: var(--orange); }
|
||||
.handle { color: var(--text); }
|
||||
.handle:hover { color: var(--orange); }
|
||||
.time, .cid, .did { color: var(--cid-fg); }
|
||||
.did { color: var(--text-dim); }
|
||||
.post__body {
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 var(--s-3);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.post__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.dot { color: var(--line-2); }
|
||||
.ago { color: var(--text-dim); }
|
||||
.spacer { flex: 1; }
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
background: transparent;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease),
|
||||
background var(--dur) var(--ease);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.action:hover:not(:disabled) {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.action:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.action--active {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
background: rgba(255, 102, 0, 0.06);
|
||||
}
|
||||
.action__icon {
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1;
|
||||
}
|
||||
.embed-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
.quote {
|
||||
margin: var(--s-2) 0 var(--s-3);
|
||||
padding: var(--s-3);
|
||||
border-left: 2px solid var(--orange-25);
|
||||
background: var(--bg-elev);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.quote__head {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--s-2);
|
||||
}
|
||||
.quote__label { color: var(--orange); }
|
||||
.quote__uri {
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.quote__loading, .quote__err {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.quote__err { color: var(--red); }
|
||||
.quote__body {
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-100);
|
||||
line-height: var(--lh-body);
|
||||
margin: 0 0 var(--s-2);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.quote__meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.quote__handle { color: var(--text); }
|
||||
.quote__handle:hover { color: var(--orange); }
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
let { rows = 3 }: { rows?: number } = $props();
|
||||
let visibleRows = $derived(Math.max(1, rows));
|
||||
</script>
|
||||
|
||||
<div class="skel" aria-busy="true" aria-live="polite">
|
||||
{#each Array.from({ length: visibleRows }) as _, i (i)}
|
||||
<div class="skel__head">
|
||||
<span class="skel__bar skel__bar--xs"></span>
|
||||
<span class="skel__bar skel__bar--sm"></span>
|
||||
<span class="skel__bar skel__bar--md"></span>
|
||||
</div>
|
||||
<div class="skel__body">
|
||||
<span class="skel__bar skel__bar--lg"></span>
|
||||
<span class="skel__bar skel__bar--lg"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.skel {
|
||||
padding: var(--s-4) var(--s-5);
|
||||
}
|
||||
.skel__head,
|
||||
.skel__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
.skel__head { align-items: center; margin-bottom: var(--s-2); }
|
||||
.skel__body {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
.skel__bar {
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-elev) 0%,
|
||||
var(--line-2) 50%,
|
||||
var(--bg-elev) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skel__bar--xs { width: 18px; height: 10px; }
|
||||
.skel__bar--sm { width: 96px; }
|
||||
.skel__bar--md { width: 140px; }
|
||||
.skel__bar--lg { width: 100%; height: 14px; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skel__bar { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
type Mode = "NORMAL" | "INSERT" | "COMPOSE";
|
||||
type Health = "ok" | "warn" | "err";
|
||||
|
||||
let { did = "", authenticated = false }: { did?: string; authenticated?: boolean } = $props();
|
||||
|
||||
let mode: Mode = $state("NORMAL");
|
||||
let pds: Health = $state("ok");
|
||||
let rev: number = $state(0);
|
||||
let lagMs: number = $state(1200);
|
||||
let now: string = $state("");
|
||||
let timer: number | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const update = () => {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
now = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
};
|
||||
update();
|
||||
timer = window.setInterval(update, 1000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
function lagColor(l: number) {
|
||||
if (l < 3000) return "var(--lag-ok)";
|
||||
if (l < 8000) return "var(--lag-warn)";
|
||||
return "var(--red)";
|
||||
}
|
||||
|
||||
function shortDid(d: string) {
|
||||
if (!d) return "did:plc:not-logged-in";
|
||||
if (d.length > 24) return d.slice(0, 14) + "…" + d.slice(-4);
|
||||
return d;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="statusbar" data-mode={mode}>
|
||||
<div class="cluster">
|
||||
<span class="mode">MODE:{mode}</span>
|
||||
<span class="dot dot-{pds}"></span>
|
||||
<span>PDS:{pds}</span>
|
||||
<span>auth:<b class:auth-ok={authenticated} class:auth-off={!authenticated}>{authenticated ? "ok" : "off"}</b></span>
|
||||
<span>rev:<b class="rev">{rev}</b></span>
|
||||
<span style="color: {lagColor(lagMs)}">lag:{lagMs}ms</span>
|
||||
<span class="did" title={did}>did:{shortDid(did)}</span>
|
||||
</div>
|
||||
<div class="time">{now}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statusbar {
|
||||
height: 24px;
|
||||
background: var(--bg-elev);
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--s-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.cluster > * { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.mode { color: var(--orange); }
|
||||
.rev { color: var(--rev-fg); }
|
||||
.auth-ok { color: var(--lag-ok); }
|
||||
.auth-off { color: var(--red); }
|
||||
.did { color: var(--cid-fg); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dot { width: 7px; height: 7px; border-radius: var(--r-pill); }
|
||||
.dot-ok { background: var(--lag-ok); animation: pulse 1.6s ease-in-out infinite; }
|
||||
.dot-warn { background: var(--lag-warn); }
|
||||
.dot-err { background: var(--red); }
|
||||
.time { color: var(--text-dim); }
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--lag-ok); }
|
||||
50% { box-shadow: 0 0 0 5px transparent; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
let { children, title = "maarcadetweet" }: { children?: any; title?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="terminal__bar">
|
||||
<div class="terminal__dots">
|
||||
<i></i><i></i><i></i>
|
||||
</div>
|
||||
<div class="terminal__title">{title}</div>
|
||||
<div class="terminal__bar-spacer"></div>
|
||||
</div>
|
||||
<div class="terminal__body">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.terminal__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.terminal__dots {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
.terminal__dots i {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--line-2);
|
||||
display: block;
|
||||
}
|
||||
.terminal__dots i:first-child { background: #4a4a4a; }
|
||||
.terminal__title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-inline: auto;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.terminal__bar-spacer { width: 36px; }
|
||||
.terminal__body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.85;
|
||||
padding: var(--s-5);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user