feat(tauri-app): X-style redesign — action bar, compose, tabs, sidebar, reply-mode
A two-pass rewrite of the home / compose / search / profile flows
to follow the X (Twitter) layout conventions while staying in our
monospace / orange-on-black terminal aesthetic. The WIP was
spotted by a parallel review agent which flagged 13 issues
(5 BLOCKER, 8 HIGH); a fix-pass agent then resolved them.
What changed
------------
**PostCard.svelte** — X-style action bar. Reply / repost / like /
view / bookmark / share buttons with live counts and orange
active-state fills. Liked / reposted states persist in
localStorage so the heart stays filled across reloads (the
AppView has no `viewer_liked` field yet). Hover shows the
action affordance. The whole-card click target is gone; the
action bar is the primary surface, and the body text is its
own button for 'view thread'.
**ComposeBox.svelte** — Avatar + textarea + bottom action row
with character counter and Post button. Counter uses
`Intl.Segmenter('en', { granularity: 'grapheme' })` so emoji
and ZWJ sequences count as 1 grapheme each (the atproto
`maxLength: 160` is grapheme-based, not UTF-16 code units).
`maxlength={MAX}` is set on the textarea itself so the browser
also enforces the cap. Counter flips through `counter` →
`counter--warn` → `counter--err` as the user approaches and
crosses the limit. Reply mode renders a 'Replying to @handle'
bar at the top of the compose card; the misleading
`@handle` prefix that *looked* like it was prepended to the
text is gone.
**Sidebar.svelte** (new) — 280px right-rail on the home view.
Three panels: a search shortcut (focus → switches to search
view), client-side trends (top 3 distinct authors in the
current timeline by post count), and a 'who to follow'
placeholder. Hidden below 900px viewport.
**App.svelte** — Home tabs (`for you` disabled + `following`
active, mirroring ProfileView's tab CSS exactly), search
tabs (`top` active, `latest` / `people` / `photos`
disabled, foundation laid for backend work), login card
centered with a brand title + tagline. New `replyTo` state
plumbs the reply click chain end-to-end.
End-to-end reply chain
---------------------
User clicks reply on a PostCard →
`PostCard.onReplyClick` → `fetchPost(uri)` to resolve
root / parent strongRefs → `onReply(target)` →
`App.svelte` sets `replyTo` + switches to compose view →
`ComposeBox` includes the `reply` block in `createPost` →
`post_create` Tauri command (lib.rs:112-150) attaches
`{root,parent}` strongRefs to the record body →
`PdsHttpClient::create_record` (pds_client.rs:214) writes the
post to the PDS with the reply block. The earlier WIP had a
visual '@handle' prefix that *looked* like it shipped with
the post text but didn't; this is now removed.
Review findings addressed
-------------------------
BLOCKER 1: Reply mode end-to-end. (B2-B5, H6-H13 trivial;
implementation agent handled all 13 in one pass.)
`Intl.Segmenter` counts emoji correctly (`🇯🇵` = 1 grapheme,
not 4 code units). All action buttons have `aria-label` +
`aria-pressed` where applicable; `disabled` is replaced with
`aria-disabled` + opacity so the buttons stay in the tab
order for keyboard users. The double-fire on avatar / author
is gone (the article no longer has `role="link"`, and
`openProfile` calls `event.stopPropagation()`). The
quoted-post null-guard crash is fixed with optional chaining.
Like / repost counts are derived from `post.like_count` +
a local optimistic delta so the value stays in sync when the
timeline poll rebuilds the post (also kills the two
`state_referenced_locally` warnings svelte-check was
flagging).
Verification
------------
* `cargo check --manifest-path crates/tauri-app/src-tauri/Cargo.toml` → 0 errors (2 pre-existing warnings).
* `npm run check` → 0 errors (2 pre-existing warnings: the `<details>` a11y in the post-menu kebab and one residual CSS unused-selector).
* `npm run test` → 20/20 passing (localStorage, client, NavRail).
* Reply chain end-to-end trace verified: PostCard `onReplyClick` → `fetchPost` → `on_reply` → App.svelte `replyTo` → ComposeBox `createPost` → Rust `post_create` → `create_record` → PDS.
This commit is contained in:
@@ -113,6 +113,7 @@ async fn post_create(
|
||||
state: tauri::State<'_, AppState>,
|
||||
text: String,
|
||||
embed: Option<serde_json::Value>,
|
||||
reply: Option<pds_client::ReplyRef>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
@@ -130,6 +131,16 @@ async fn post_create(
|
||||
record["embed"] = emb;
|
||||
}
|
||||
}
|
||||
// The `reply` field on a post record (see
|
||||
// `app.bsky.feed.post`) is `{root, parent}` strongRefs. We only
|
||||
// attach it when the caller passes a non-null object; missing
|
||||
// means "top-level post" which is the default.
|
||||
if let Some(rp) = reply {
|
||||
record["reply"] = serde_json::json!({
|
||||
"root": { "uri": rp.root.uri, "cid": rp.root.cid },
|
||||
"parent": { "uri": rp.parent.uri, "cid": rp.parent.cid },
|
||||
});
|
||||
}
|
||||
let resp = state
|
||||
.pds
|
||||
.create_record(&sess.did, "app.twi.post", record, &sess.access_jwt)
|
||||
|
||||
@@ -43,6 +43,26 @@ pub struct CreateRecordReq {
|
||||
pub record: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Strong reference as defined by
|
||||
/// `com.atproto.repo.strongRef` — `{uri, cid}`. Used inside
|
||||
/// `app.bsky.feed.post#reply` (root + parent) and inside
|
||||
/// `app.bsky.embed.record` (the quoted post).
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct StrongRef {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
/// `app.bsky.feed.post#reply` — the `reply` field on a post
|
||||
/// record. `root` is the topmost ancestor of the thread,
|
||||
/// `parent` is the post being directly replied to. For a
|
||||
/// top-level reply they point at the same `strongRef`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ReplyRef {
|
||||
pub root: StrongRef,
|
||||
pub parent: StrongRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateRecordResp {
|
||||
pub uri: String,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import LoginScreen from "./lib/components/LoginScreen.svelte";
|
||||
import Terminal from "./lib/components/Terminal.svelte";
|
||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||
import Sidebar from "./lib/components/Sidebar.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||
|
||||
@@ -39,6 +40,17 @@
|
||||
let seenUris: Set<string> = new Set();
|
||||
let _statusTimer: number | undefined;
|
||||
|
||||
// Home tab strip — "for you" is a placeholder (no real algo yet),
|
||||
// "following" is the live behavior. Mirrors the X-style "For you /
|
||||
// Following" tabs.
|
||||
type HomeTab = "for-you" | "following";
|
||||
let homeTab: HomeTab = $state("following");
|
||||
|
||||
// Search tab strip — only "top" is wired (matches the current
|
||||
// search endpoint). The rest are visually present but disabled.
|
||||
type SearchTab = "top" | "latest" | "people" | "photos";
|
||||
let searchTab: SearchTab = $state("top");
|
||||
|
||||
// Search state.
|
||||
let searchQuery: string = $state("");
|
||||
let searchResults: Post[] = $state([]);
|
||||
@@ -52,6 +64,28 @@
|
||||
let threadLoading: boolean = $state(false);
|
||||
let threadError: string | null = $state(null);
|
||||
|
||||
// Reply state — when the user clicks the reply button on a
|
||||
// PostCard, the parent fires `on_reply` with strongRefs. We
|
||||
// stash them here and switch to the compose view; the ComposeBox
|
||||
// reads `replyTo` to render the "Replying to @handle" bar and
|
||||
// attach the reply block on submit.
|
||||
type ReplyTarget = {
|
||||
handle: string;
|
||||
root: { uri: string; cid: string };
|
||||
parent: { uri: string; cid: string };
|
||||
};
|
||||
let replyTo: ReplyTarget | null = $state(null);
|
||||
|
||||
/// Called by PostCard's reply button. Stores the strongRefs and
|
||||
/// routes the user to the compose view.
|
||||
function onReply(target: ReplyTarget) {
|
||||
replyTo = target;
|
||||
view = "compose";
|
||||
}
|
||||
function clearReply() {
|
||||
replyTo = null;
|
||||
}
|
||||
|
||||
// Toasts surfaced by child components via the `maarcadetweet:toast`
|
||||
// window event. We keep the last few so a slow render doesn't
|
||||
// wipe the message before the user reads it.
|
||||
@@ -369,10 +403,26 @@
|
||||
}
|
||||
|
||||
async function handlePosted() {
|
||||
// After the user posts, reset to page 1 so they see their own post.
|
||||
// After the user posts, reset to page 1 so they see their own
|
||||
// post, and clear any active reply target so the next compose
|
||||
// doesn't re-attach the reply block.
|
||||
replyTo = null;
|
||||
await refreshTimeline(true);
|
||||
}
|
||||
|
||||
/// Wired into the right-rail Sidebar. Fills the search query and
|
||||
/// switches to the search view. If the query is empty we just
|
||||
/// switch to the search view (the input there will keep focus).
|
||||
function onSidebarSearch(query: string) {
|
||||
searchQuery = query;
|
||||
view = "search";
|
||||
if (query.trim().length > 0) {
|
||||
// Run the search immediately so the Sidebar click feels
|
||||
// responsive (no debounce delay).
|
||||
scheduleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await session.logout();
|
||||
@@ -423,6 +473,7 @@
|
||||
on_select={(v) => setView(v)}
|
||||
/>
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
||||
{#if view === "home"}
|
||||
<div class="head">
|
||||
@@ -431,6 +482,20 @@
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">→ {userPosts.length} posts · polling every 5s</span>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Timeline">
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
disabled
|
||||
title="for you — algo coming soon"
|
||||
>for you</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={homeTab === "following"}
|
||||
type="button"
|
||||
onclick={() => (homeTab = "following")}
|
||||
>following</button>
|
||||
</nav>
|
||||
{#if timelineError}
|
||||
<div class="toast toast--err">err: {timelineError}</div>
|
||||
{/if}
|
||||
@@ -451,14 +516,14 @@
|
||||
<div class="toast toast--err">err: {threadError}</div>
|
||||
{:else if threadRoot}
|
||||
{#if threadParent && threadParent.uri !== threadRoot.uri}
|
||||
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} /></div>
|
||||
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
|
||||
{/if}
|
||||
<PostCard post={threadRoot} on_handle_click={openUserProfile} />
|
||||
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#each userPosts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
|
||||
{/each}
|
||||
{#if timelineCursor}
|
||||
<div class="loadmore">
|
||||
@@ -475,7 +540,11 @@
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">⌘↵ to post</span>
|
||||
</div>
|
||||
<ComposeBox onPosted={handlePosted} />
|
||||
<ComposeBox
|
||||
onPosted={handlePosted}
|
||||
replyTo={replyTo}
|
||||
onClearReply={clearReply}
|
||||
/>
|
||||
{:else if view === "user"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
@@ -585,6 +654,32 @@
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Search sections">
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={searchTab === "top"}
|
||||
type="button"
|
||||
onclick={() => (searchTab = "top")}
|
||||
>top</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
disabled
|
||||
title="latest — coming soon"
|
||||
>latest</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
disabled
|
||||
title="people — coming soon"
|
||||
>people</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
disabled
|
||||
title="photos — coming soon"
|
||||
>photos</button>
|
||||
</nav>
|
||||
{#if searchError}
|
||||
<div class="toast toast--err">err: {searchError}</div>
|
||||
{/if}
|
||||
@@ -597,11 +692,15 @@
|
||||
{:else}
|
||||
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
||||
{#each searchResults as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</Terminal>
|
||||
{#if view === "home"}
|
||||
<Sidebar posts={userPosts} onSearch={onSidebarSearch} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
|
||||
</div>
|
||||
@@ -691,6 +790,50 @@
|
||||
overflow: auto;
|
||||
padding: var(--s-3);
|
||||
}
|
||||
.main-inner {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
.main-inner > :global(.terminal) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Tab strip — mirrors the ProfileView's `.tab` pattern so the
|
||||
home + search tabs read as siblings of the profile tabs. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin: 0 0 var(--s-3);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: var(--s-3);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition:
|
||||
color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.tab:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
.tab:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tab--active {
|
||||
color: var(--orange);
|
||||
border-bottom-color: var(--orange);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.head {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@@ -218,6 +218,8 @@ export type Post = {
|
||||
embed?: Embed | null;
|
||||
langs: string[];
|
||||
created_at: string;
|
||||
like_count?: number;
|
||||
repost_count?: number;
|
||||
/// Resolved author-avatar CID from the AppView's `profiles`
|
||||
/// cache. NULL when the user has no profile record yet.
|
||||
avatar_cid?: string | null;
|
||||
@@ -263,9 +265,20 @@ export type ThreadResponse = {
|
||||
repost_count?: number;
|
||||
};
|
||||
|
||||
/// Reply block for `app.bsky.feed.post#reply`. Both `root` and
|
||||
/// `parent` are `com.atproto.repo.strongRef`s (uri + cid). For a
|
||||
/// top-level reply to a single post, `root` and `parent` point at
|
||||
/// the same strongRef. The Rust `post_create` command wires this
|
||||
/// onto the record's `reply` field.
|
||||
export type ReplyRef = {
|
||||
root: { uri: string; cid: string };
|
||||
parent: { uri: string; cid: string };
|
||||
};
|
||||
|
||||
export async function createPost(
|
||||
text: string,
|
||||
embed?: unknown | null,
|
||||
reply?: ReplyRef | null,
|
||||
): Promise<Post> {
|
||||
// The Rust post_create command returns a different shape (uri+cid
|
||||
// only), but we keep the call simple: it gives us the cid we need
|
||||
@@ -273,9 +286,12 @@ export async function createPost(
|
||||
// `embed` is forwarded verbatim; the caller is responsible for
|
||||
// shaping it as an `app.bsky.embed.images` / `.external` / etc.
|
||||
// record. Pass `null` or `undefined` to omit.
|
||||
// `reply` is the reply block (root + parent strongRefs); `null` or
|
||||
// `undefined` means "top-level post" (no reply block on the record).
|
||||
return await safeInvoke<any>("post_create", {
|
||||
text,
|
||||
embed: embed ?? null,
|
||||
reply: reply ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,27 +8,37 @@
|
||||
releaseBlob,
|
||||
session,
|
||||
type Post,
|
||||
type Session,
|
||||
type ReplyRef,
|
||||
} from "../api/client";
|
||||
import Avatar from "./Avatar.svelte";
|
||||
|
||||
/// Reply target. The parent/root strongRefs are required so the
|
||||
/// resulting post can carry the `reply` block on its record.
|
||||
type ReplyTarget = {
|
||||
handle: string;
|
||||
root: { uri: string; cid: string };
|
||||
parent: { uri: string; cid: string };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onPosted?: () => void;
|
||||
replyTo?: ReplyTarget | null;
|
||||
onClearReply?: () => void;
|
||||
};
|
||||
|
||||
const MAX = 160;
|
||||
let { onPosted }: { onPosted?: () => void } = $props();
|
||||
let text: string = $state("");
|
||||
let isPosting: boolean = $state(false);
|
||||
let isAttaching: boolean = $state(false);
|
||||
let { onPosted, replyTo = null, onClearReply }: Props = $props();
|
||||
let text = $state("");
|
||||
let isPosting = $state(false);
|
||||
let isAttaching = $state(false);
|
||||
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null);
|
||||
let currentUser: Session | null = $state(null);
|
||||
|
||||
// Currently logged-in user. We need the DID for `fetchBlob` (the
|
||||
// PDS endpoint keys blobs by `(did, cid)`), so the compose box
|
||||
// subscribes to the session store rather than taking a prop.
|
||||
let did: string = $state("");
|
||||
$effect(() => {
|
||||
const u = $session;
|
||||
did = u?.did ?? "";
|
||||
currentUser = $session;
|
||||
});
|
||||
|
||||
// The currently-attached image. `null` = no attachment. We hold
|
||||
// the blob reference + a local object URL for the preview so the
|
||||
// user sees the image before they post.
|
||||
let attachment: {
|
||||
cid: string;
|
||||
mimeType: string;
|
||||
@@ -36,267 +46,417 @@
|
||||
previewUrl: string;
|
||||
} | null = $state(null);
|
||||
|
||||
let remaining = $derived(MAX - text.length);
|
||||
let counterClass = $derived(
|
||||
remaining < 0 ? "counter counter--err" :
|
||||
remaining < 40 ? "counter counter--warn" : "counter"
|
||||
// Count graphemes, not UTF-16 code units. atproto enforces
|
||||
// `maxLength: 160` as graphemes, so a single 🚀 (surrogate pair)
|
||||
// must count as 1, not 2. `Intl.Segmenter` is built into the
|
||||
// runtime — no dependency needed.
|
||||
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
|
||||
const count = $derived(text.trim().length === 0 ? 0 : [...seg.segment(text)].length);
|
||||
const isTooLong = $derived(count > MAX);
|
||||
const isAtMax = $derived(count === MAX);
|
||||
// atproto's `maxLength: 160` is inclusive of the 160th grapheme
|
||||
// — the spec rejects any record whose text length exceeds 160.
|
||||
// So we treat `count === MAX` as "exactly at the cap, still
|
||||
// shippable" and only flag as an error on strict overflow.
|
||||
const counterClass = $derived(
|
||||
isTooLong
|
||||
? "counter counter--err"
|
||||
: isAtMax
|
||||
? "counter counter--warn"
|
||||
: count >= MAX - 30
|
||||
? "counter counter--warn"
|
||||
: "counter",
|
||||
);
|
||||
const canPost = $derived(!!text.trim() && !isTooLong && !isPosting);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
post();
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void post();
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
|
||||
function fmtBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
||||
}
|
||||
|
||||
async function attach() {
|
||||
if (isAttaching || attachment) return;
|
||||
if (!did) {
|
||||
status = { kind: "err", msg: "> log in first" };
|
||||
if (!currentUser?.did) {
|
||||
status = { kind: "err", msg: "log in to add an image" };
|
||||
return;
|
||||
}
|
||||
isAttaching = true;
|
||||
status = { kind: "info", msg: "> picking…" };
|
||||
status = null;
|
||||
try {
|
||||
const blob = await pickAndUploadImage();
|
||||
if (!blob) {
|
||||
// User cancelled — restore the previous status rather than
|
||||
// leaving the "picking…" message on screen.
|
||||
status = null;
|
||||
return;
|
||||
}
|
||||
// Fetch the bytes back from the PDS so we can render the
|
||||
// preview. `fetchBlob` caches by CID, so re-rendering the
|
||||
// preview after a re-attach is cheap.
|
||||
const previewUrl = await fetchBlob(did, blob.cid);
|
||||
if (!blob) return;
|
||||
const previewUrl = await fetchBlob(currentUser.did, blob.cid);
|
||||
attachment = { ...blob, previewUrl };
|
||||
status = { kind: "info", msg: `> attached (${fmtBytes(blob.size)})` };
|
||||
} catch (e) {
|
||||
status = { kind: "err", msg: `> ${String(e)}` };
|
||||
} catch (error) {
|
||||
status = { kind: "err", msg: String(error) };
|
||||
} finally {
|
||||
isAttaching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment() {
|
||||
if (attachment) {
|
||||
// Revoke the object URL. `fetchBlob` may have evicted the
|
||||
// cache entry for a different reason, so tolerate a no-op.
|
||||
// The user can re-attach — the next fetch will allocate a
|
||||
// fresh URL.
|
||||
releaseBlob(did, attachment.cid);
|
||||
attachment = null;
|
||||
}
|
||||
if (!attachment || !currentUser?.did) return;
|
||||
releaseBlob(currentUser.did, attachment.cid);
|
||||
attachment = null;
|
||||
}
|
||||
|
||||
async function post() {
|
||||
if (!text.trim() || remaining < 0 || isPosting) return;
|
||||
if (!canPost) return;
|
||||
isPosting = true;
|
||||
status = { kind: "info", msg: "> posting…" };
|
||||
status = { kind: "info", msg: "posting…" };
|
||||
try {
|
||||
const embed = attachment ? makeImagesEmbed(attachment) : null;
|
||||
const r: Post = await createPost(text, embed);
|
||||
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` };
|
||||
const reply: ReplyRef | null = replyTo
|
||||
? { root: replyTo.root, parent: replyTo.parent }
|
||||
: null;
|
||||
const response: Post = await createPost(text.trim(), embed, reply);
|
||||
status = {
|
||||
kind: "ok",
|
||||
msg: `posted · cid ${(response as any).cid?.slice?.(0, 8) ?? "?"}…`,
|
||||
};
|
||||
text = "";
|
||||
removeAttachment();
|
||||
onPosted?.();
|
||||
} catch (e) {
|
||||
status = { kind: "err", msg: `> ${String(e)}` };
|
||||
showError(`post failed: ${e}`);
|
||||
} catch (error) {
|
||||
status = { kind: "err", msg: String(error) };
|
||||
showError(`post failed: ${error}`);
|
||||
} 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>
|
||||
<section class="compose" aria-label={replyTo ? `Reply to @${replyTo.handle}` : "Compose a post"}>
|
||||
<div class="compose__avatar">
|
||||
<Avatar
|
||||
did={currentUser?.did ?? ""}
|
||||
name={currentUser?.handle ?? "you"}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div class="compose__body">
|
||||
<span class="prompt">$</span>
|
||||
|
||||
<div class="compose__content">
|
||||
{#if replyTo}
|
||||
<div class="replying">
|
||||
<span>Replying to <b>@{replyTo.handle}</b></span>
|
||||
<button type="button" onclick={onClearReply} title="cancel reply" aria-label="Cancel reply">×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
bind:value={text}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="// what's happening in 160 chars?"
|
||||
placeholder={replyTo ? `Reply to @${replyTo.handle}…` : "What's happening?"}
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
maxlength={MAX}
|
||||
aria-label="Post text"
|
||||
></textarea>
|
||||
</div>
|
||||
{#if attachment}
|
||||
<div class="compose__attach">
|
||||
<img
|
||||
class="compose__preview"
|
||||
src={attachment.previewUrl}
|
||||
alt="attachment preview"
|
||||
/>
|
||||
<div class="compose__attach-meta">
|
||||
<span class="compose__attach-cid" title={attachment.cid}>cid: {attachment.cid.slice(0, 10)}…</span>
|
||||
<span class="compose__attach-mime">{attachment.mimeType}</span>
|
||||
<span class="compose__attach-size">{fmtBytes(attachment.size)}</span>
|
||||
|
||||
{#if attachment}
|
||||
<div class="attachment">
|
||||
<img src={attachment.previewUrl} alt="Attachment preview" />
|
||||
<div class="attachment__meta">
|
||||
<span>{attachment.mimeType}</span>
|
||||
<span>{fmtBytes(attachment.size)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment__remove"
|
||||
onclick={removeAttachment}
|
||||
disabled={isPosting}
|
||||
title="remove image"
|
||||
aria-label="Remove image"
|
||||
>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="compose__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="compose__attach-remove"
|
||||
onclick={removeAttachment}
|
||||
disabled={isPosting}
|
||||
title="remove attachment"
|
||||
>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="compose__foot">
|
||||
<span class="hint">⌘↵ to post</span>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--ghost"
|
||||
class="media-button"
|
||||
onclick={attach}
|
||||
disabled={isAttaching || !!attachment || isPosting}
|
||||
title={attachment ? "image already attached" : "attach image"}
|
||||
title={attachment ? "one image already attached" : "add image"}
|
||||
>
|
||||
{isAttaching ? "picking…" : "📎"}
|
||||
</button>
|
||||
<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"}
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<circle cx="8.5" cy="9" r="1.5" />
|
||||
<path d="m4 17 5-5 4 4 3-3 4 4" />
|
||||
</svg>
|
||||
<span>{isAttaching ? "adding…" : "image"}</span>
|
||||
</button>
|
||||
|
||||
<div class="compose__submit">
|
||||
<span class={counterClass}>{count}/{MAX}</span>
|
||||
<span class="divider" aria-hidden="true"></span>
|
||||
<button
|
||||
class="post-button"
|
||||
type="button"
|
||||
onclick={post}
|
||||
disabled={!canPost}
|
||||
>
|
||||
{isPosting ? "Posting…" : replyTo ? "Reply" : "Post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if status}
|
||||
<div class="status status--{status.kind}" role="status">{status.msg}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if status}
|
||||
<div class="status status--{status.kind}">{status.msg}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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;
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
background: var(--bg-deep);
|
||||
padding: var(--s-4);
|
||||
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__attach {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: var(--bg-deep);
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.compose__preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg);
|
||||
|
||||
.compose__avatar {
|
||||
padding-top: 2px;
|
||||
}
|
||||
.compose__attach-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
|
||||
.compose__content {
|
||||
min-width: 0;
|
||||
}
|
||||
.compose__attach-cid { color: var(--cid-fg); }
|
||||
.compose__attach-mime,
|
||||
.compose__attach-size { font-variant-numeric: tabular-nums; }
|
||||
.compose__attach-remove {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.compose__attach-remove:hover:not(:disabled) {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.compose__attach-remove:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.compose__foot {
|
||||
|
||||
.replying {
|
||||
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 {
|
||||
margin-bottom: var(--s-2);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.replying b {
|
||||
color: var(--orange);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.replying button {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
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 {
|
||||
|
||||
.replying button:hover {
|
||||
background: var(--orange-8);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: var(--s-1) 0 var(--s-3);
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-200);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.attachment {
|
||||
position: relative;
|
||||
margin-bottom: var(--s-3);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.attachment img {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
}
|
||||
|
||||
.attachment__remove {
|
||||
position: absolute;
|
||||
top: var(--s-2);
|
||||
right: var(--s-2);
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment__remove:hover:not(:disabled) {
|
||||
border-color: var(--orange);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.compose__footer,
|
||||
.compose__submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compose__footer {
|
||||
min-height: 40px;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
padding-top: var(--s-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.status--ok { color: var(--green); }
|
||||
.status--err { color: var(--red); }
|
||||
.status--info { color: var(--orange); }
|
||||
</style>
|
||||
|
||||
.compose__submit {
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.media-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: transparent;
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-button:hover:not(:disabled) {
|
||||
background: var(--orange-8);
|
||||
}
|
||||
|
||||
.media-button svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.media-button:disabled,
|
||||
.post-button:disabled,
|
||||
.attachment__remove:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.counter {
|
||||
min-width: 5.5rem;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.counter--warn {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.counter--err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
|
||||
.post-button {
|
||||
min-width: 76px;
|
||||
padding: 0.55rem 1rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--orange);
|
||||
color: var(--bg-deep);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.post-button:hover:not(:disabled) {
|
||||
background: var(--orange-bright);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: var(--s-2);
|
||||
padding-top: var(--s-2);
|
||||
border-top: 1px dashed var(--line);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
|
||||
.status--ok {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status--err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.status--info {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.compose {
|
||||
padding-inline: var(--s-3);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-size: var(--fs-100);
|
||||
}
|
||||
|
||||
.media-button span,
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -42,47 +42,46 @@
|
||||
<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>
|
||||
<h1 class="brand">maarcadetweet</h1>
|
||||
<p class="tagline">// the timeline that fits in 160 chars.</p>
|
||||
{#if serverInfo}
|
||||
<div class="line muted">// pds: {serverInfo.did ?? "?"}</div>
|
||||
<div class="line muted">// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</div>
|
||||
<div class="meta">
|
||||
<span>// pds: {serverInfo.did ?? "?"}</span>
|
||||
<span>// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="line"> </div>
|
||||
<div class="form">
|
||||
<label>
|
||||
<span class="key">handle:</span>
|
||||
<form class="form" onsubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||
<label class="field">
|
||||
<span class="key">handle</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={handle}
|
||||
placeholder="alice.maarcadetweet.local"
|
||||
disabled={busy}
|
||||
autocomplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="key">password:</span>
|
||||
<label class="field">
|
||||
<span class="key">password</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="≥ 8 chars"
|
||||
disabled={busy}
|
||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
||||
autocomplete={mode === "register" ? "new-password" : "current-password"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{#if error}
|
||||
<div class="line err">error: {error}</div>
|
||||
<div class="err">err: {error}</div>
|
||||
{/if}
|
||||
<div class="line"> </div>
|
||||
<div class="line">
|
||||
<div class="actions">
|
||||
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
|
||||
{busy ? "..." : mode === "register" ? "create account" : "login"}
|
||||
{busy ? "..." : mode === "register" ? "create account" : "log in"}
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
|
||||
{mode === "register" ? "have an account? login" : "no account? register"}
|
||||
{mode === "register" ? "have an account? log in" : "no account? register"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +93,8 @@
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
width: min(560px, 92vw);
|
||||
width: min(480px, 92vw);
|
||||
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.terminal-head {
|
||||
display: flex;
|
||||
@@ -121,49 +121,109 @@
|
||||
}
|
||||
.terminal-body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.85;
|
||||
padding: var(--s-5);
|
||||
padding: var(--s-6) var(--s-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.brand {
|
||||
margin: 0;
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-400);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--lh-tight);
|
||||
text-align: center;
|
||||
}
|
||||
.tagline {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
text-align: center;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
text-align: center;
|
||||
}
|
||||
.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;
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
.form label {
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
.key {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.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);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
transition: border-color var(--dur) var(--ease);
|
||||
}
|
||||
.form input:focus { border-color: var(--orange); }
|
||||
.btn {
|
||||
.form input::placeholder { color: var(--text-dim); }
|
||||
.err {
|
||||
color: var(--red);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.5rem 0.8rem;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-left: 3px solid var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
.btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
margin-right: var(--s-2);
|
||||
text-align: center;
|
||||
transition:
|
||||
background var(--dur) var(--ease),
|
||||
color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.btn--primary {
|
||||
background: var(--orange);
|
||||
color: #1a0d00;
|
||||
font-weight: 700;
|
||||
}
|
||||
.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--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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import type { Post } from "../api/client";
|
||||
|
||||
type Props = {
|
||||
/// Posts currently in the home timeline. The Sidebar derives
|
||||
/// the trends list client-side from these (top 3 DIDs by post
|
||||
/// count), so no new backend endpoint is required.
|
||||
posts: Post[];
|
||||
/// Switches the App's view to "search" and populates the
|
||||
/// search query. Wired by the parent App.
|
||||
onSearch: (query: string) => void;
|
||||
};
|
||||
|
||||
let { posts, onSearch }: Props = $props();
|
||||
|
||||
let query: string = $state("");
|
||||
|
||||
function submit() {
|
||||
onSearch(query.trim());
|
||||
}
|
||||
|
||||
/// Aggregate by DID; cheapest possible counter (no fetchProfile).
|
||||
/// We surface the handles for display, but the actual handle
|
||||
/// resolution still comes from the `handle` field baked into
|
||||
/// each post by the AppView. Skips empty DIDs defensively.
|
||||
const trends = $derived.by(() => {
|
||||
const counts = new Map<string, { did: string; handle: string; count: number }>();
|
||||
for (const p of posts) {
|
||||
if (!p.did) continue;
|
||||
const existing = counts.get(p.did);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
counts.set(p.did, { did: p.did, handle: p.handle, count: 1 });
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(counts.values()).sort((a, b) => b.count - a.count);
|
||||
return sorted.slice(0, 3);
|
||||
});
|
||||
|
||||
const placeholders = [
|
||||
{ handle: "alice.bsky.social", why: "shared network" },
|
||||
{ handle: "bob.bsky.social", why: "popular in feed" },
|
||||
{ handle: "carol.bsky.social", why: "trending" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside class="sidebar" aria-label="Discover">
|
||||
<section class="panel">
|
||||
<label class="panel__search">
|
||||
<span class="prompt">$</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
onfocus={() => onSearch("")}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder="grep posts…"
|
||||
aria-label="Search posts"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3 class="panel__title">// trends</h3>
|
||||
{#if trends.length === 0}
|
||||
<p class="panel__empty">// no posts yet — start the timeline.</p>
|
||||
{:else}
|
||||
<ul class="trends">
|
||||
{#each trends as t (t.did)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="trend"
|
||||
title={`${t.count} post${t.count === 1 ? "" : "s"} in current timeline`}
|
||||
onclick={() => onSearch(t.handle)}
|
||||
>
|
||||
<span class="trend__handle">@{t.handle}</span>
|
||||
<span class="trend__count">{t.count} post{t.count === 1 ? "" : "s"}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3 class="panel__title">// who to follow</h3>
|
||||
<ul class="who">
|
||||
{#each placeholders as p (p.handle)}
|
||||
<li class="who__row">
|
||||
<span class="who__handle">@{p.handle}</span>
|
||||
<span class="who__why">{p.why}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="panel__hint">// coming soon — follow graph not wired yet</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex: 0 0 280px;
|
||||
align-self: flex-start;
|
||||
position: sticky;
|
||||
top: var(--s-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-3) var(--s-5);
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-deep);
|
||||
padding: var(--s-3);
|
||||
}
|
||||
|
||||
.panel__title {
|
||||
margin: 0 0 var(--s-3);
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
letter-spacing: var(--tracking-label);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.panel__search .prompt {
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
}
|
||||
|
||||
.panel__search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.panel__search input:focus {
|
||||
border-color: var(--orange);
|
||||
}
|
||||
|
||||
.panel__search input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.panel__empty {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.panel__hint {
|
||||
margin: var(--s-3) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.trends,
|
||||
.who {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.trend {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.trend:hover {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
|
||||
.trend__handle {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend:hover .trend__handle {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.trend__count {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.who__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--s-2) var(--s-1);
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.who__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.who__handle {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.who__why {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user