Compare commits
9
Commits
ffee5c6685
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baeb87214b | ||
|
|
48ee25f217 | ||
|
|
eb62fd5654 | ||
|
|
a98f891e4f | ||
|
|
4c71b76763 | ||
|
|
aba84cbaa9 | ||
|
|
e6aa28ca4c | ||
|
|
e4bcfbfa83 | ||
|
|
6ebf17b493 |
@@ -22,6 +22,7 @@ use axum::{
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -31,6 +32,21 @@ pub mod types;
|
||||
use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse};
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
// CORS: the Tauri webview's origin is the Vite dev server
|
||||
// (`http://127.0.0.1:1430`) in dev or the bundled `tauri://` /
|
||||
// `asset://` origin in production. Either way it's a cross-origin
|
||||
// fetch against this service's `http://127.0.0.1:2584` listen
|
||||
// address, so the browser blocks the response without an explicit
|
||||
// allow-origin header. We allow any origin — the AppView's
|
||||
// public read endpoints (`/api/...`) carry no auth cookie and
|
||||
// the AppView runs alongside the user's own PDS, not on the
|
||||
// open internet; production deployments behind a reverse proxy
|
||||
// can tighten this via the proxy itself.
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/api/timeline/home", get(timeline_home))
|
||||
@@ -40,6 +56,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/post/*uri", get(post_by_uri))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -343,13 +360,31 @@ async fn resolve_profile(
|
||||
// Order by `indexed_at DESC` so we get the most recent DID for
|
||||
// this handle (a single user can re-use a handle if account
|
||||
// history allows, but the latest is the active one).
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT did FROM posts WHERE handle = $1 ORDER BY indexed_at DESC LIMIT 1",
|
||||
//
|
||||
// Prefer the `profiles` cache over `posts` — a user can have
|
||||
// a profile row (set via PDS push before posting) but no posts
|
||||
// yet, and we want the profile page to render with the right
|
||||
// DID rather than synthesise an empty one.
|
||||
let row = sqlx::query_scalar::<_, String>(
|
||||
"SELECT did FROM profiles WHERE LOWER(handle) = LOWER($1) \
|
||||
ORDER BY indexed_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(h)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map_err(db_err)?;
|
||||
if row.is_some() {
|
||||
row
|
||||
} else {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT did FROM posts WHERE handle = $1 \
|
||||
ORDER BY indexed_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(h)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
@@ -273,6 +284,76 @@ async fn unrepost_post(
|
||||
}))
|
||||
}
|
||||
|
||||
/// `follow_user(target_did)` — create an `app.bsky.graph.follow`
|
||||
/// record on the user's PDS pointing at `target_did`. Returns the
|
||||
/// new record's URI (the client caches this in localStorage so it
|
||||
/// can be deleted by `unfollow_user` without an extra round-trip).
|
||||
///
|
||||
/// `subject` in the follow record is just a DID string, not a
|
||||
/// strong-ref — the PDS is the source of truth for which follow
|
||||
/// record belongs to which subject.
|
||||
#[tauri::command]
|
||||
async fn follow_user(
|
||||
state: tauri::State<'_, AppState>,
|
||||
target_did: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
if target_did == sess.did {
|
||||
return Err("can't follow yourself".into());
|
||||
}
|
||||
let record = serde_json::json!({
|
||||
"$type": "app.bsky.graph.follow",
|
||||
"subject": target_did,
|
||||
"createdAt": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let resp = state
|
||||
.pds
|
||||
.create_record(
|
||||
&sess.did,
|
||||
"app.bsky.graph.follow",
|
||||
record,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": resp.uri,
|
||||
"cid": resp.cid,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `unfollow_user(follow_uri)` — delete the previously-created
|
||||
/// follow record. The client passes the cached URI from its
|
||||
/// `localStorage` so we don't need a separate "list my follows"
|
||||
/// endpoint to find the right rkey.
|
||||
#[tauri::command]
|
||||
async fn unfollow_user(
|
||||
state: tauri::State<'_, AppState>,
|
||||
follow_uri: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let rkey = rkey_from_uri(&follow_uri)?;
|
||||
let resp = state
|
||||
.pds
|
||||
.delete_record(
|
||||
&sess.did,
|
||||
"app.bsky.graph.follow",
|
||||
&rkey,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"commit": resp.commit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn timeline_home(
|
||||
state: tauri::State<'_, AppState>,
|
||||
@@ -543,8 +624,9 @@ pub fn run() {
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
||||
|
||||
let state = AppState {
|
||||
pds: PdsHttpClient::new(pds_url),
|
||||
appview: AppViewClient::new(appview_url),
|
||||
pds: PdsHttpClient::new(pds_url.clone()),
|
||||
appview: AppViewClient::new(appview_url.clone()),
|
||||
appview_url,
|
||||
store: store::SessionStore::new(),
|
||||
};
|
||||
|
||||
@@ -715,6 +797,8 @@ pub fn run() {
|
||||
unlike_post,
|
||||
repost_post,
|
||||
unrepost_post,
|
||||
follow_user,
|
||||
unfollow_user,
|
||||
status_pds,
|
||||
fetch_blob,
|
||||
pick_and_upload_image,
|
||||
@@ -722,6 +806,7 @@ pub fn run() {
|
||||
open_external_url,
|
||||
profile_get_record,
|
||||
profile_set,
|
||||
get_api_urls,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running maarcadetweet");
|
||||
@@ -741,6 +826,32 @@ async fn profile_get_record(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Frontend-side base URLs the Tauri shell was started with. Used by
|
||||
/// the Svelte components to build absolute fetch URLs — a relative
|
||||
/// `/api/...` resolves against the Vite dev origin (port 1430), not
|
||||
/// the AppView (port 2584), and the Vite server has no proxy
|
||||
/// configured, so the fetch lands on a 404 HTML page and
|
||||
/// `response.json()` throws `SyntaxError`.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiUrls {
|
||||
pds_url: String,
|
||||
appview_url: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_api_urls(state: tauri::State<'_, AppState>) -> ApiUrls {
|
||||
// Sync command — the URLs are immutable for the lifetime of the
|
||||
// Tauri shell (read from MAARCADETWEET_*_URL at startup), so no
|
||||
// async machinery is needed. Returns the AppView URL the
|
||||
// frontend needs; PDS URL is exposed too so future fetch-based
|
||||
// XRPC calls don't have to add their own command.
|
||||
ApiUrls {
|
||||
pds_url: state.pds.base_url.clone(),
|
||||
appview_url: state.appview_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn profile_set(
|
||||
state: tauri::State<'_, AppState>,
|
||||
|
||||
@@ -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,
|
||||
@@ -385,6 +405,8 @@ pub struct UploadedBlobRef {
|
||||
#[serde(rename = "$link")]
|
||||
pub link: String,
|
||||
}
|
||||
|
||||
impl PdsHttpClient {
|
||||
/// `POST /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.actor.profile&rkey=self`
|
||||
/// Returns the record's CBOR-decoded value as JSON, or `None` if no
|
||||
/// record exists for that path. The server replies with a
|
||||
@@ -446,3 +468,4 @@ pub struct UploadedBlobRef {
|
||||
let v: serde_json::Value = r.json().await?;
|
||||
Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
pub struct AppState {
|
||||
pub pds: crate::pds_client::PdsHttpClient,
|
||||
pub appview: crate::appview_client::AppViewClient,
|
||||
/// Base URL of the AppView service (`http://host:port`, no
|
||||
/// trailing slash). Stored verbatim so the frontend can build
|
||||
/// absolute URLs for fetch calls — a relative `/api/profile/…`
|
||||
/// would resolve against the Vite dev origin, not the AppView.
|
||||
pub appview_url: String,
|
||||
pub store: crate::store::SessionStore,
|
||||
}
|
||||
|
||||
+339
-384
@@ -4,26 +4,22 @@
|
||||
session,
|
||||
pdsStatus,
|
||||
fetchTimeline,
|
||||
fetchProfile,
|
||||
fetchSearch,
|
||||
fetchPost,
|
||||
openExternalUrl,
|
||||
showError,
|
||||
pickAndUploadImage,
|
||||
setMyProfile,
|
||||
type Session,
|
||||
type Post,
|
||||
type ProfileResponse,
|
||||
} from "./lib/api/client";
|
||||
import NavRail from "./lib/components/NavRail.svelte";
|
||||
import StatusBar from "./lib/components/StatusBar.svelte";
|
||||
import PostCard from "./lib/components/PostCard.svelte";
|
||||
import ComposeBox from "./lib/components/ComposeBox.svelte";
|
||||
import UserProfileView from "./lib/components/UserProfileView.svelte";
|
||||
import Avatar from "./lib/components/Avatar.svelte";
|
||||
import ProfileView from "./lib/components/ProfileView.svelte";
|
||||
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";
|
||||
|
||||
@@ -44,49 +40,16 @@
|
||||
let seenUris: Set<string> = new Set();
|
||||
let _statusTimer: number | undefined;
|
||||
|
||||
// Profile state.
|
||||
let profile: ProfileResponse | null = $state(null);
|
||||
let profileLoading: boolean = $state(false);
|
||||
let profileError: string | null = $state(null);
|
||||
// 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");
|
||||
|
||||
// Edit-profile state.
|
||||
let editingProfile: boolean = $state(false);
|
||||
let editProfileName: string = $state("");
|
||||
let editProfileDesc: string = $state("");
|
||||
let editProfileAvatarCid: string | null = $state(null);
|
||||
let savingProfile: boolean = $state(false);
|
||||
|
||||
async function pickAndUploadAvatar() {
|
||||
try {
|
||||
const r = await pickAndUploadImage();
|
||||
if (r) editProfileAvatarCid = r.cid;
|
||||
} catch (e) {
|
||||
console.warn("avatar upload failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!currentUser) return;
|
||||
savingProfile = true;
|
||||
try {
|
||||
const updated = await setMyProfile({
|
||||
displayName: editProfileName || undefined,
|
||||
description: editProfileDesc || undefined,
|
||||
avatarBlobCid: editProfileAvatarCid || undefined,
|
||||
});
|
||||
// Refresh the cached profile from the response (or re-fetch).
|
||||
if (updated) {
|
||||
profile = { ...profile, ...updated } as ProfileResponse | null;
|
||||
} else {
|
||||
await refreshProfile(currentUser.handle);
|
||||
}
|
||||
editingProfile = false;
|
||||
} catch (e) {
|
||||
console.warn("profile save failed", e);
|
||||
} finally {
|
||||
savingProfile = false;
|
||||
}
|
||||
}
|
||||
// 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("");
|
||||
@@ -101,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.
|
||||
@@ -133,7 +118,7 @@
|
||||
/// Navigate to the "user" profile view for `handle`. Called from
|
||||
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
|
||||
/// the post header. The actual profile fetch happens inside
|
||||
/// `<UserProfileView>` on mount.
|
||||
/// `<ProfileView>` on mount.
|
||||
function openUserProfile(handle: string) {
|
||||
selectedHandle = handle;
|
||||
view = "user";
|
||||
@@ -306,8 +291,8 @@
|
||||
void refreshTimeline(true);
|
||||
}
|
||||
if (next === "profile") {
|
||||
const handle = currentUser.handle;
|
||||
void refreshProfile(handle);
|
||||
// ProfileView fetches its own data on mount; nothing to
|
||||
// preload here.
|
||||
}
|
||||
if (next === "search" && searchQuery.trim().length > 0) {
|
||||
scheduleSearch();
|
||||
@@ -376,19 +361,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProfile(handle: string) {
|
||||
profileLoading = true;
|
||||
profileError = null;
|
||||
try {
|
||||
profile = await fetchProfile(handle);
|
||||
} catch (e) {
|
||||
profileError = String(e);
|
||||
profile = null;
|
||||
} finally {
|
||||
profileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch() {
|
||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||
_searchDebounce = window.setTimeout(() => {
|
||||
@@ -431,15 +403,30 @@
|
||||
}
|
||||
|
||||
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();
|
||||
setView("home");
|
||||
profile = null;
|
||||
searchResults = [];
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
@@ -451,13 +438,6 @@
|
||||
// Derive a display handle. The session already gives us the user's
|
||||
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
||||
// posts that have empty handles it falls back to a synthetic
|
||||
// "@did:plc:abcd…" form, so the fallback here matches that.
|
||||
function displayHandle(h: string | null | undefined): string {
|
||||
if (!h) return "@unknown";
|
||||
if (h.startsWith("@")) return h;
|
||||
return `@${h}`;
|
||||
}
|
||||
|
||||
// Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL /
|
||||
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
|
||||
// Used in the Settings view to show which backends the client is
|
||||
@@ -493,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">
|
||||
@@ -501,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}
|
||||
@@ -521,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">
|
||||
@@ -545,235 +540,125 @@
|
||||
<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>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{selectedHandle}</span>
|
||||
</div>
|
||||
<UserProfileView
|
||||
<ProfileView
|
||||
handle={selectedHandle}
|
||||
on_thread_click={openThread}
|
||||
current_user_did={currentUser?.did ?? null}
|
||||
/>
|
||||
{:else if view === "profile"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
</div>
|
||||
{#if profileLoading && !profile}
|
||||
<Skeleton rows={4} />
|
||||
{:else if profileError}
|
||||
<div class="toast toast--err">err: {profileError}</div>
|
||||
{:else if profile}
|
||||
<section class="profile">
|
||||
<header class="profile__head">
|
||||
<div class="profile__handle">{displayHandle(profile.handle)}</div>
|
||||
<div class="profile__did" title={profile.did}>{profile.did}</div>
|
||||
{#if profile.avatar_cid}
|
||||
<Avatar
|
||||
did={profile.did}
|
||||
cid={profile.avatar_cid}
|
||||
name={profile.display_name ?? profile.handle}
|
||||
size={64}
|
||||
/>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if profile.description}
|
||||
<div class="profile__bio">{profile.description}</div>
|
||||
{:else}
|
||||
<div class="profile__bio profile__bio--empty">
|
||||
// no profile yet — click "edit profile" to set one up.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="profile__actions">
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
onclick={() => (editingProfile = !editingProfile)}
|
||||
>
|
||||
{editingProfile ? "cancel" : "edit profile"}
|
||||
</button>
|
||||
</div>
|
||||
{#if editingProfile}
|
||||
<div class="profile__edit">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="display name"
|
||||
maxlength="64"
|
||||
bind:value={editProfileName}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="description"
|
||||
rows="3"
|
||||
maxlength="300"
|
||||
bind:value={editProfileDesc}
|
||||
></textarea>
|
||||
<div class="profile__edit-avatar">
|
||||
{#if editProfileAvatarCid}
|
||||
<span class="meta">cid: {editProfileAvatarCid.slice(0, 10)}…</span>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
onclick={() => (editProfileAvatarCid = null)}
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
{:else}
|
||||
<span class="meta">no avatar</span>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
onclick={pickAndUploadAvatar}
|
||||
>upload…</button>
|
||||
</div>
|
||||
<div class="profile__edit-actions">
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
disabled={savingProfile}
|
||||
onclick={saveProfile}
|
||||
>
|
||||
{savingProfile ? "saving…" : "save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="profile__stats">
|
||||
<span>{profile.post_count} posts</span>
|
||||
<span>{profile.followers} followers</span>
|
||||
<span>{profile.following} following</span>
|
||||
</div>
|
||||
|
||||
<div class="profile__actions">
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
title="Copy DID to clipboard"
|
||||
onclick={() => copyToClipboard(profile!.did)}
|
||||
>copy did</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
title="Copy AT URI to clipboard"
|
||||
onclick={() =>
|
||||
copyToClipboard(`at://${profile!.did}/app.twi.post`)}
|
||||
>copy at-uri</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
title="Open profile in your default browser"
|
||||
onclick={() =>
|
||||
openExternalUrl(
|
||||
`https://bsky.app/profile/${profile!.handle}`,
|
||||
)}
|
||||
>open in browser</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
title="Sign out of this app"
|
||||
onclick={handleLogout}
|
||||
>sign out</button>
|
||||
</div>
|
||||
|
||||
<dl class="counts">
|
||||
<div>
|
||||
<dt>followers</dt>
|
||||
<dd>{profile.followers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>following</dt>
|
||||
<dd>{profile.following}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>posts</dt>
|
||||
<dd>{profile.posts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if profile.posts.length === 0}
|
||||
<div class="empty">// no posts yet — compose your first one</div>
|
||||
{:else}
|
||||
<h3 class="profile__h3">// recent posts</h3>
|
||||
{#each profile.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{#if currentUser}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
</div>
|
||||
<ProfileView
|
||||
handle={currentUser.handle}
|
||||
on_thread_click={openThread}
|
||||
current_user_did={currentUser.did}
|
||||
/>
|
||||
{/if}
|
||||
{:else if view === "settings"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// settings</span>
|
||||
<span class="meta">@{currentUser?.handle ?? "?"}</span>
|
||||
</div>
|
||||
<section class="settings">
|
||||
<h3 class="settings__h3">// account</h3>
|
||||
<dl class="settings__rows">
|
||||
<div>
|
||||
<dt>handle</dt>
|
||||
<dd>@{currentUser?.handle ?? "?"}</dd>
|
||||
<!-- Account — X-style rows: label left, value right, full-width clickable -->
|
||||
<div class="settings__group">
|
||||
<h3 class="settings__h3">// account</h3>
|
||||
<div class="settings__list">
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">handle</span>
|
||||
<span class="settings__value">@{currentUser?.handle ?? "?"}</span>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">did</span>
|
||||
<code class="settings__value settings__value--mono">{currentUser?.did ?? "?"}</code>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">posts cached</span>
|
||||
<span class="settings__value">{userPosts.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<dt>did</dt>
|
||||
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
|
||||
<div class="settings__actions">
|
||||
<button
|
||||
class="settings__action"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
currentUser && copyToClipboard(currentUser.did)}
|
||||
>
|
||||
<span>copy did</span>
|
||||
<span class="settings__action-hint">atproto</span>
|
||||
</button>
|
||||
<button
|
||||
class="settings__action"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openExternalUrl(
|
||||
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
|
||||
)}
|
||||
>
|
||||
<span>open profile in browser</span>
|
||||
<span class="settings__action-hint">↗ bsky.app</span>
|
||||
</button>
|
||||
<button
|
||||
class="settings__action"
|
||||
type="button"
|
||||
onclick={() => setView("home")}
|
||||
>
|
||||
<span>← back to timeline</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<dt>posts in cache</dt>
|
||||
<dd>{userPosts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h3 class="settings__h3">// actions</h3>
|
||||
<div class="settings__actions">
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
currentUser && copyToClipboard(currentUser.did)}
|
||||
>copy my did</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openExternalUrl(
|
||||
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
|
||||
)}
|
||||
>open profile in browser</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() => setView("home")}
|
||||
>← back to timeline</button>
|
||||
</div>
|
||||
|
||||
<h3 class="settings__h3">// about</h3>
|
||||
<dl class="settings__rows">
|
||||
<div>
|
||||
<dt>app</dt>
|
||||
<dd>maarcadetweet</dd>
|
||||
<!-- Backend / connection info — same row pattern -->
|
||||
<div class="settings__group">
|
||||
<h3 class="settings__h3">// backend</h3>
|
||||
<div class="settings__list">
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">app</span>
|
||||
<span class="settings__value">maarcadetweet</span>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">version</span>
|
||||
<span class="settings__value">0.1.0</span>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">pds</span>
|
||||
<code class="settings__value settings__value--mono">{pdsBase()}</code>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">appview</span>
|
||||
<code class="settings__value settings__value--mono">{appviewBase()}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<dt>version</dt>
|
||||
<dd>0.1.0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>backend</dt>
|
||||
<dd>{pdsBase()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>appview</dt>
|
||||
<dd>{appviewBase()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="settings__signout">
|
||||
<button
|
||||
class="btn btn--ghost btn--danger"
|
||||
type="button"
|
||||
onclick={handleLogout}
|
||||
>sign out</button>
|
||||
<!-- Sign-out — separate danger zone at the bottom, like X's "Log out" row -->
|
||||
<div class="settings__group settings__group--danger">
|
||||
<div class="settings__list">
|
||||
<button
|
||||
class="settings__action settings__action--danger"
|
||||
type="button"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
<span>sign out</span>
|
||||
<span class="settings__action-hint">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if view === "search"}
|
||||
@@ -788,6 +673,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}
|
||||
@@ -800,11 +711,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>
|
||||
@@ -894,12 +809,51 @@
|
||||
overflow: auto;
|
||||
padding: var(--s-3);
|
||||
}
|
||||
|
||||
.profile__actions {
|
||||
.main-inner {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
margin: var(--s-3) 0;
|
||||
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);
|
||||
font-size: var(--fs-50);
|
||||
@@ -972,61 +926,6 @@
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.profile {
|
||||
padding: 0 var(--s-3);
|
||||
}
|
||||
.profile__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-3) 0 var(--s-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
.profile__handle {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
color: var(--orange);
|
||||
}
|
||||
.profile__did {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile__bio {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
color: var(--text);
|
||||
padding: var(--s-2) 0;
|
||||
}
|
||||
.profile__bio--empty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.counts {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
margin: 0 0 var(--s-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.counts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.counts dt { color: var(--text-dim); letter-spacing: 0.04em; }
|
||||
.counts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: var(--s-4);
|
||||
@@ -1056,68 +955,124 @@
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
/* (the legacy .btn--danger class used to be applied to the
|
||||
"sign out" button — that's now styled via
|
||||
`.settings__group--danger .settings__action` which is its own
|
||||
selector tree in the settings section below) */
|
||||
|
||||
.profile__h3,
|
||||
/* X-style settings page: sectioned cards with label-left /
|
||||
value-right rows, then a list of clickable action rows, then
|
||||
a danger zone at the bottom. Stays monospace + terminal-
|
||||
commented, but the structure is the same as X's. */
|
||||
.settings {
|
||||
padding: 0 var(--s-3) var(--s-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
.settings__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
.settings__h3 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
margin: var(--s-4) 0 var(--s-2);
|
||||
font-weight: 400;
|
||||
color: var(--orange);
|
||||
letter-spacing: var(--tracking-label);
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.did-cell {
|
||||
word-break: break-all;
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
|
||||
.settings {
|
||||
padding: 0 var(--s-3);
|
||||
.settings__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings__rows {
|
||||
/* Each row is a label-left / value-right flex line, separated
|
||||
by a hairline (X uses a single border on each row except the
|
||||
last). */
|
||||
.settings__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
margin: 0 0 var(--s-4);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.settings__rows > div {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
.settings__list .settings__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.settings__rows dt {
|
||||
.settings__label {
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 9rem;
|
||||
letter-spacing: var(--tracking-label);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.settings__rows dd {
|
||||
margin: 0;
|
||||
.settings__value {
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings__value--mono {
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
/* Actions live in their own list — same border-radius but each
|
||||
item is a full-width clickable button. The hint on the right
|
||||
(e.g. "atproto", "↗ bsky.app") is a dim secondary label, the
|
||||
same way X shows the destination on follow / open-in-app
|
||||
rows. */
|
||||
.settings__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
margin: 0 0 var(--s-4);
|
||||
flex-direction: column;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings__signout {
|
||||
margin-top: var(--s-4);
|
||||
padding-top: var(--s-4);
|
||||
border-top: 1px dashed var(--line);
|
||||
.settings__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--dur) var(--ease),
|
||||
color var(--dur) var(--ease);
|
||||
}
|
||||
.settings__actions .settings__action:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.settings__action:hover {
|
||||
background: var(--orange-8);
|
||||
color: var(--orange);
|
||||
}
|
||||
.settings__action-hint {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.settings__action:hover .settings__action-hint {
|
||||
color: var(--orange);
|
||||
}
|
||||
.settings__group--danger .settings__action {
|
||||
color: var(--red);
|
||||
}
|
||||
.settings__group--danger .settings__action:hover {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
color: var(--red);
|
||||
}
|
||||
.settings__group--danger {
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,33 @@ async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unkn
|
||||
return invoke<T>(cmd, args);
|
||||
}
|
||||
|
||||
/// Base URLs the Tauri shell was started with. Exposed via the
|
||||
/// `get_api_urls` command so the Svelte components can build
|
||||
/// absolute fetch URLs — a relative `/api/...` resolves against
|
||||
/// the Vite dev origin (port 1430), not the AppView (port 2584),
|
||||
/// and `response.json()` then throws `SyntaxError` on the 404
|
||||
/// HTML page. Cached after the first successful call.
|
||||
let _apiUrlsCache: { pdsUrl: string; appviewUrl: string } | null = null;
|
||||
|
||||
export type ApiUrls = { pdsUrl: string; appviewUrl: string };
|
||||
|
||||
/// Fetch the AppView + PDS base URLs from the Rust shell. Returns
|
||||
/// the cached value on subsequent calls.
|
||||
export async function getApiUrls(): Promise<ApiUrls> {
|
||||
if (_apiUrlsCache) return _apiUrlsCache;
|
||||
const urls = await safeInvoke<ApiUrls>("get_api_urls");
|
||||
_apiUrlsCache = urls;
|
||||
return urls;
|
||||
}
|
||||
|
||||
/// Convenience: just the AppView base URL (the only one the UI
|
||||
/// currently needs for direct fetch calls). Same caching as
|
||||
/// `getApiUrls`.
|
||||
export async function getAppviewUrl(): Promise<string> {
|
||||
const { appviewUrl } = await getApiUrls();
|
||||
return appviewUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant of `tauriCall` for actions that MUST hit the
|
||||
* Tauri runtime (login, register, logout, post, like, etc.). In
|
||||
@@ -191,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;
|
||||
@@ -236,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
|
||||
@@ -246,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,10 +421,28 @@ export async function unrepostPost(
|
||||
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
||||
}
|
||||
|
||||
/// Fire-and-forget user-visible error toast. Implemented as a
|
||||
/// `window` `CustomEvent` so any component can show errors without
|
||||
/// pulling in a global store. `App.svelte` listens for the event
|
||||
/// and renders the toast UI.
|
||||
/// `followUser(targetDid)` — create an `app.bsky.graph.follow` record
|
||||
/// on the user's PDS. Returns `{ uri, cid }` — the client caches
|
||||
/// `uri` in localStorage so `unfollowUser(uri)` can delete the
|
||||
/// record without needing a "list my follows" round-trip.
|
||||
export async function followUser(
|
||||
targetDid: string,
|
||||
): Promise<RepoWriteResult> {
|
||||
return await safeInvoke<RepoWriteResult>("follow_user", { targetDid });
|
||||
}
|
||||
|
||||
export async function unfollowUser(
|
||||
followUri: string,
|
||||
): Promise<DeleteRecordResult> {
|
||||
return await safeInvoke<DeleteRecordResult>("unfollow_user", {
|
||||
followUri,
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget user-visible toast. Implemented as a `window`
|
||||
/// `CustomEvent` so any component can show toasts without pulling
|
||||
/// in a global store. `App.svelte` listens for the event and
|
||||
/// renders the toast UI.
|
||||
export function showError(text: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
@@ -389,6 +450,13 @@ export function showError(text: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function showInfo(text: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("maarcadetweet:toast", { detail: { kind: "info", text } }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a native OS notification. Thin wrapper around the
|
||||
/// `show_notification` Tauri command. The Rust side also emits an
|
||||
/// `app://notification` event with the same payload, so the click
|
||||
|
||||
@@ -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); }
|
||||
|
||||
.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>
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
let { onLogin }: { onLogin: (s: Session) => void } = $props();
|
||||
|
||||
let mode: "login" | "register" = $state("register");
|
||||
// Default to "login" — most users opening the app already have an
|
||||
// account, and the empty-autocomplete form now matches the
|
||||
// action-label pair they expect. "register" is one click away.
|
||||
let mode: "login" | "register" = $state("login");
|
||||
let handle: string = $state("");
|
||||
let password: string = $state("");
|
||||
let busy = $state(false);
|
||||
@@ -42,47 +45,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 +96,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 +124,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,811 @@
|
||||
<script lang="ts">
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import PostCard from "./PostCard.svelte";
|
||||
import {
|
||||
setMyProfile,
|
||||
pickAndUploadImage,
|
||||
fetchBlob,
|
||||
releaseBlob,
|
||||
getAppviewUrl,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
showInfo,
|
||||
showError,
|
||||
} from "../api/client";
|
||||
import { localStorageKey } from "../utils/localstorage";
|
||||
import { onDestroy, onMount, untrack } from "svelte";
|
||||
|
||||
type Props = {
|
||||
handle: string;
|
||||
on_thread_click?: (uri: string) => void;
|
||||
/// DID of the authenticated user. When this matches the
|
||||
/// profile's DID, the "edit profile" button is shown; the
|
||||
/// /user-profile/<handle> route is then the user's own
|
||||
/// profile (and the avatar / bio are editable).
|
||||
current_user_did?: string | null;
|
||||
};
|
||||
let { handle, on_thread_click, current_user_did }: Props = $props();
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; data: AppViewProfile };
|
||||
|
||||
type AppViewProfile = {
|
||||
did: string;
|
||||
handle: string;
|
||||
posts: AppViewPost[];
|
||||
followers: number;
|
||||
following: number;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
avatar_cid?: string;
|
||||
banner_cid?: string;
|
||||
post_count: number;
|
||||
};
|
||||
|
||||
type AppViewPost = {
|
||||
uri: string;
|
||||
did: string;
|
||||
handle: string;
|
||||
rkey: string;
|
||||
collection: string;
|
||||
text: string;
|
||||
cid: string;
|
||||
parent_uri?: string | null;
|
||||
root_uri?: string | null;
|
||||
embed?: null;
|
||||
langs: string[];
|
||||
created_at: string;
|
||||
avatar_cid?: string | null;
|
||||
};
|
||||
|
||||
let editing: boolean = $state(false);
|
||||
// Type-annotated so TypeScript keeps the discriminated-union narrowing
|
||||
// when we do `viewModel.kind === "ready"` — otherwise $state infers
|
||||
// the literal `"loading"` from the initial value and the `===`
|
||||
// checks become "no overlap" errors.
|
||||
let viewModel: State = $state({ kind: "loading" } as State);
|
||||
let editName: string = $state("");
|
||||
let editDesc: string = $state("");
|
||||
let editAvatarCid: string | null = $state(null);
|
||||
let saving: boolean = $state(false);
|
||||
|
||||
// Follow state — the AppView has no `viewer_followed` field yet, so
|
||||
// we persist per-viewer follow state in localStorage (keyed by
|
||||
// viewer-did + target-did). `followUri` is the URI of the
|
||||
// `app.bsky.graph.follow` record on the viewer's PDS — the unfollow
|
||||
// command needs it because atproto requires the rkey to delete a
|
||||
// record, and we don't have a "list my follows" endpoint to look
|
||||
// it up server-side.
|
||||
let isFollowing: boolean = $state(false);
|
||||
let followUri: string | null = $state(null);
|
||||
let followBusy: boolean = $state(false);
|
||||
|
||||
// Banner blob URL — fetched via the same path as Avatar (Tauri
|
||||
// getBlob via fetchBlob). Released on unmount or when banner
|
||||
// changes.
|
||||
let bannerUrl: string | null = $state(null);
|
||||
let bannerCidLoaded: string | null = null;
|
||||
|
||||
async function load() {
|
||||
viewModel = { kind: "loading" };
|
||||
try {
|
||||
// Absolute URL because the Tauri webview's origin is the Vite
|
||||
// dev server (port 1430), not the AppView (port 2584) — a
|
||||
// relative `/api/profile/…` would resolve against Vite, hit a
|
||||
// 404 HTML page, and `r.json()` would throw `SyntaxError`.
|
||||
const base = await getAppviewUrl();
|
||||
const r = await fetch(`${base}/api/profile/${encodeURIComponent(handle)}`);
|
||||
if (!r.ok) {
|
||||
viewModel = {
|
||||
kind: "error",
|
||||
message: `profile fetch failed: ${r.status}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const data: AppViewProfile = await r.json();
|
||||
viewModel = { kind: "ready", data };
|
||||
} catch (e) {
|
||||
viewModel = { kind: "error", message: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const bannerCid =
|
||||
viewModel.kind === "ready" ? viewModel.data.banner_cid ?? null : null;
|
||||
// Release the previous URL whenever the banner CID changes
|
||||
// (including to/from null). We read the previous-loaded value
|
||||
// through `untrack` because reading + writing `bannerCidLoaded`
|
||||
// inside the same effect would trip Svelte 5's depth guard
|
||||
// (`effect_update_depth_exceeded`).
|
||||
const previous = untrack(() => bannerCidLoaded);
|
||||
if (previous === bannerCid) return;
|
||||
if (bannerUrl) {
|
||||
if (viewModel.kind === "ready" && viewModel.data.did) {
|
||||
releaseBlob(viewModel.data.did, previous ?? "");
|
||||
}
|
||||
URL.revokeObjectURL(bannerUrl);
|
||||
bannerUrl = null;
|
||||
}
|
||||
bannerCidLoaded = bannerCid;
|
||||
if (!bannerCid || viewModel.kind !== "ready") return;
|
||||
const did = viewModel.data.did;
|
||||
let cancelled = false;
|
||||
fetchBlob(did, bannerCid)
|
||||
.then((u) => {
|
||||
if (!cancelled) bannerUrl = u;
|
||||
else URL.revokeObjectURL(u);
|
||||
})
|
||||
.catch(() => {
|
||||
/* fall back to CSS gradient placeholder */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (bannerUrl) URL.revokeObjectURL(bannerUrl);
|
||||
});
|
||||
|
||||
const isOwn = $derived(
|
||||
!!current_user_did &&
|
||||
viewModel.kind === "ready" &&
|
||||
viewModel.data.did === current_user_did,
|
||||
);
|
||||
const isEmptyProfile = $derived(
|
||||
viewModel.kind === "ready" &&
|
||||
!viewModel.data.display_name &&
|
||||
!viewModel.data.description &&
|
||||
!viewModel.data.avatar_cid &&
|
||||
!viewModel.data.banner_cid,
|
||||
);
|
||||
|
||||
// Restore follow state from localStorage whenever the profile
|
||||
// (DID) changes. Writes are inside `untrack` so the effect's
|
||||
// reactive dep set is just `[viewModel.kind, viewModel.data.did]`
|
||||
// — without untrack, every write to `isFollowing` / `followUri`
|
||||
// would re-enter the effect and trip Svelte's depth guard.
|
||||
$effect(() => {
|
||||
if (viewModel.kind !== "ready" || !current_user_did) return;
|
||||
const did = viewModel.data.did;
|
||||
const key = localStorageKey(`follow:${current_user_did}:${did}`);
|
||||
untrack(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
following: boolean;
|
||||
uri: string | null;
|
||||
};
|
||||
isFollowing = !!parsed.following;
|
||||
followUri = parsed.uri ?? null;
|
||||
} else {
|
||||
isFollowing = false;
|
||||
followUri = null;
|
||||
}
|
||||
} catch {
|
||||
isFollowing = false;
|
||||
followUri = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function persistFollow(following: boolean, uri: string | null) {
|
||||
if (viewModel.kind !== "ready" || !current_user_did) return;
|
||||
const did = viewModel.data.did;
|
||||
const key = localStorageKey(`follow:${current_user_did}:${did}`);
|
||||
try {
|
||||
if (following) {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ following: true, uri }),
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
/* quota / private mode — fall through */
|
||||
}
|
||||
}
|
||||
|
||||
async function onFollowClick() {
|
||||
if (viewModel.kind !== "ready" || !current_user_did) return;
|
||||
if (followBusy) return;
|
||||
const targetDid = viewModel.data.did;
|
||||
if (targetDid === current_user_did) return;
|
||||
followBusy = true;
|
||||
const wasFollowing = isFollowing;
|
||||
const previousUri = followUri;
|
||||
isFollowing = true;
|
||||
try {
|
||||
const resp = await followUser(targetDid);
|
||||
followUri = resp.uri;
|
||||
persistFollow(true, resp.uri);
|
||||
showInfo("followed");
|
||||
} catch (e) {
|
||||
isFollowing = wasFollowing;
|
||||
followUri = previousUri;
|
||||
persistFollow(wasFollowing, previousUri);
|
||||
showError(`follow failed: ${e}`);
|
||||
} finally {
|
||||
followBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnfollowClick() {
|
||||
if (viewModel.kind !== "ready") return;
|
||||
if (followBusy) return;
|
||||
if (!followUri) {
|
||||
// Nothing to unfollow — clear the flag and bail.
|
||||
isFollowing = false;
|
||||
return;
|
||||
}
|
||||
followBusy = true;
|
||||
const wasFollowing = isFollowing;
|
||||
const previousUri = followUri;
|
||||
isFollowing = false;
|
||||
followUri = null;
|
||||
persistFollow(false, null);
|
||||
try {
|
||||
await unfollowUser(previousUri!);
|
||||
showInfo("unfollowed");
|
||||
} catch (e) {
|
||||
isFollowing = wasFollowing;
|
||||
followUri = previousUri;
|
||||
persistFollow(wasFollowing, previousUri);
|
||||
showError(`unfollow failed: ${e}`);
|
||||
} finally {
|
||||
followBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (viewModel.kind !== "ready") return;
|
||||
editName = viewModel.data.display_name ?? "";
|
||||
editDesc = viewModel.data.description ?? "";
|
||||
editAvatarCid = viewModel.data.avatar_cid ?? null;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (viewModel.kind !== "ready") return;
|
||||
saving = true;
|
||||
try {
|
||||
await setMyProfile({
|
||||
displayName: editName || undefined,
|
||||
description: editDesc || undefined,
|
||||
avatarBlobCid: editAvatarCid || undefined,
|
||||
});
|
||||
editing = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.warn("profile save failed", e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickAndUploadAvatar() {
|
||||
const blob = await pickAndUploadImage();
|
||||
if (!blob) return;
|
||||
editAvatarCid = blob.cid;
|
||||
}
|
||||
|
||||
type Tab = "posts" | "replies" | "likes";
|
||||
let activeTab: Tab = $state("posts");
|
||||
</script>
|
||||
|
||||
<section class="profile">
|
||||
<!-- ─── banner ────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="profile__banner"
|
||||
style:background-image={bannerUrl ? `url(${bannerUrl})` : "none"}
|
||||
>
|
||||
{#if !bannerUrl}
|
||||
<!--
|
||||
Placeholder shown when the profile has no banner blob.
|
||||
Subtle orange-tinted terminal grid — keeps the page from
|
||||
looking bare without competing with the avatar.
|
||||
-->
|
||||
<div class="profile__banner-grid" aria-hidden="true"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ─── avatar + actions ──────────────────────────────────────── -->
|
||||
<div class="profile__topbar">
|
||||
<div class="profile__avatar-overlap">
|
||||
{#if viewModel.kind === "ready"}
|
||||
<Avatar
|
||||
did={viewModel.data.did}
|
||||
cid={viewModel.data.avatar_cid ?? null}
|
||||
name={viewModel.data.display_name ?? viewModel.data.handle}
|
||||
size={96}
|
||||
/>
|
||||
{:else}
|
||||
<span class="profile__avatar-skeleton"></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="profile__actions">
|
||||
{#if viewModel.kind === "ready"}
|
||||
{#if isOwn}
|
||||
{#if editing}
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() => (editing = false)}
|
||||
>cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="button"
|
||||
onclick={openEdit}
|
||||
>edit profile</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<!--
|
||||
Follow toggle. Text + class flip with `isFollowing`:
|
||||
"follow" / `.btn--primary` (outlined-emphasis) when not
|
||||
following, "following" / `.btn--ghost` (subdued) when
|
||||
already following. The "following" click becomes an
|
||||
unfollow via the same handler — X shows the relationship
|
||||
state in the label, not a separate "unfollow" button.
|
||||
-->
|
||||
{#if isFollowing}
|
||||
<button
|
||||
class="btn btn--ghost profile__follow-btn profile__follow-btn--active"
|
||||
type="button"
|
||||
disabled={followBusy}
|
||||
onclick={onUnfollowClick}
|
||||
>following</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn--primary profile__follow-btn"
|
||||
type="button"
|
||||
disabled={followBusy}
|
||||
onclick={onFollowClick}
|
||||
>follow</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── identity ──────────────────────────────────────────────── -->
|
||||
{#if viewModel.kind === "ready"}
|
||||
<div class="profile__identity">
|
||||
<h2 class="profile__name">
|
||||
{viewModel.data.display_name ?? viewModel.data.handle}
|
||||
</h2>
|
||||
<div class="profile__handle">@{viewModel.data.handle}</div>
|
||||
</div>
|
||||
{:else if viewModel.kind === "loading"}
|
||||
<div class="profile__identity">
|
||||
<h2 class="profile__name profile__name--skeleton">…</h2>
|
||||
<div class="profile__handle">@{handle}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="profile__identity profile__identity--err">
|
||||
err: {viewModel.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── bio ───────────────────────────────────────────────────── -->
|
||||
{#if viewModel.kind === "ready"}
|
||||
{#if viewModel.data.description}
|
||||
<p class="profile__bio">{viewModel.data.description}</p>
|
||||
{:else if isEmptyProfile}
|
||||
<p class="profile__bio profile__bio--empty">
|
||||
{#if isOwn}
|
||||
// no profile yet — click "edit profile" to set one up.
|
||||
{:else}
|
||||
// no profile yet.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ─── meta (did) ───────────────────────────────────────────── -->
|
||||
{#if viewModel.kind === "ready"}
|
||||
<div class="profile__meta">
|
||||
<span class="profile__meta-item" title={viewModel.data.did}>
|
||||
did: <code>{shortDid(viewModel.data.did)}</code>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── counts ────────────────────────────────────────────────── -->
|
||||
{#if viewModel.kind === "ready"}
|
||||
<dl class="profile__counts">
|
||||
<div>
|
||||
<dt>posts</dt>
|
||||
<dd>{viewModel.data.post_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>followers</dt>
|
||||
<dd>{viewModel.data.followers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>following</dt>
|
||||
<dd>{viewModel.data.following}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
<!-- ─── tabs ──────────────────────────────────────────────────── -->
|
||||
<nav class="profile__tabs" aria-label="Profile sections">
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={activeTab === "posts"}
|
||||
type="button"
|
||||
onclick={() => (activeTab = "posts")}
|
||||
>posts</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={activeTab === "replies"}
|
||||
type="button"
|
||||
disabled
|
||||
title="replies — coming soon"
|
||||
>replies</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={activeTab === "likes"}
|
||||
type="button"
|
||||
disabled
|
||||
title="likes — coming soon"
|
||||
>likes</button>
|
||||
</nav>
|
||||
|
||||
<!-- ─── feed ──────────────────────────────────────────────────── -->
|
||||
<div class="profile__feed">
|
||||
{#if viewModel.kind === "ready"}
|
||||
{#each viewModel.data.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={on_thread_click} />
|
||||
{/each}
|
||||
{#if viewModel.data.posts.length === 0}
|
||||
<div class="profile__empty">// no posts yet.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ─── edit form (own profile only) ─────────────────────────── -->
|
||||
{#if editing && viewModel.kind === "ready"}
|
||||
<div class="profile__edit">
|
||||
<h3 class="profile__edit-title">// edit profile</h3>
|
||||
<label class="profile__edit-field">
|
||||
<span class="key">display name</span>
|
||||
<input type="text" bind:value={editName} maxlength="64" />
|
||||
</label>
|
||||
<label class="profile__edit-field">
|
||||
<span class="key">description</span>
|
||||
<textarea
|
||||
bind:value={editDesc}
|
||||
rows="3"
|
||||
maxlength="300"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="profile__edit-field">
|
||||
<span class="key">avatar</span>
|
||||
<div class="profile__edit-row">
|
||||
{#if editAvatarCid}
|
||||
<span class="meta">cid: {editAvatarCid.slice(0, 10)}…</span>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() => (editAvatarCid = null)}
|
||||
>clear</button>
|
||||
{:else}
|
||||
<span class="meta">none</span>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={pickAndUploadAvatar}
|
||||
>upload…</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile__edit-actions">
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onclick={saveProfile}
|
||||
>
|
||||
{saving ? "saving…" : "save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<script lang="ts" module>
|
||||
/// Compact DID renderer for the profile meta line — keeps the
|
||||
/// `did:plc:bafyreiczj…` from spilling past the column.
|
||||
export function shortDid(did: string): string {
|
||||
if (did.length <= 24) return did;
|
||||
const head = did.slice(0, 18);
|
||||
const tail = did.slice(-6);
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* No outer padding — banner + avatar overhang make the section
|
||||
fill the column edge to edge on mobile. */
|
||||
|
||||
/* ─── banner ─────────────────────────────────────────────── */
|
||||
.profile__banner {
|
||||
position: relative;
|
||||
height: 140px;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-elev);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
.profile__banner-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--bg-elev) 0%,
|
||||
rgba(255, 102, 0, 0.12) 60%,
|
||||
rgba(255, 102, 0, 0.04) 100%
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent 0,
|
||||
transparent 27px,
|
||||
rgba(255, 102, 0, 0.06) 27px,
|
||||
rgba(255, 102, 0, 0.06) 28px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
transparent 27px,
|
||||
rgba(255, 102, 0, 0.06) 27px,
|
||||
rgba(255, 102, 0, 0.06) 28px
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── avatar + actions ──────────────────────────────────── */
|
||||
.profile__topbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--s-4);
|
||||
margin-top: -44px;
|
||||
min-height: 52px;
|
||||
}
|
||||
.profile__avatar-overlap {
|
||||
border: 4px solid var(--bg);
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
line-height: 0;
|
||||
}
|
||||
.profile__avatar-skeleton {
|
||||
display: inline-block;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.profile__actions {
|
||||
padding-bottom: var(--s-3);
|
||||
}
|
||||
/* Follow button — X-style with two states. "follow" is the
|
||||
full orange emphasis (btn--primary); "following" flips to a
|
||||
ghost button that turns red on hover (mirroring X's
|
||||
"unfollow on hover" affordance). */
|
||||
.profile__follow-btn {
|
||||
min-width: 6.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.profile__follow-btn--active {
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
background: transparent;
|
||||
}
|
||||
.profile__follow-btn--active:hover:not(:disabled) {
|
||||
/* X's "unfollow on hover" — replace label + colour with the
|
||||
destructive cue, but only while actually hovering. */
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
|
||||
/* ─── identity ──────────────────────────────────────────── */
|
||||
.profile__identity {
|
||||
padding: var(--s-3) var(--s-4) 0;
|
||||
}
|
||||
.profile__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-300);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: var(--lh-tight);
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.profile__name--skeleton {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.profile__handle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.profile__identity--err {
|
||||
color: var(--red);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
}
|
||||
|
||||
/* ─── bio ───────────────────────────────────────────────── */
|
||||
.profile__bio {
|
||||
padding: var(--s-3) var(--s-4) 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
color: var(--text);
|
||||
line-height: var(--lh-body);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.profile__bio--empty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ─── meta ──────────────────────────────────────────────── */
|
||||
.profile__meta {
|
||||
padding: var(--s-3) var(--s-4) 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.profile__meta code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ─── counts ────────────────────────────────────────────── */
|
||||
.profile__counts {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.profile__counts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.profile__counts dt {
|
||||
color: var(--text-dim);
|
||||
letter-spacing: var(--tracking-label);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.profile__counts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─── tabs ──────────────────────────────────────────────── */
|
||||
.profile__tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-top: var(--s-2);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ─── feed ──────────────────────────────────────────────── */
|
||||
.profile__feed {
|
||||
padding: var(--s-2) 0 var(--s-6);
|
||||
}
|
||||
.profile__empty {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
padding: var(--s-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── edit form ─────────────────────────────────────────── */
|
||||
.profile__edit {
|
||||
border-top: 1px solid var(--line);
|
||||
margin: var(--s-4) var(--s-4) 0;
|
||||
padding: var(--s-4) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.profile__edit-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
color: var(--orange);
|
||||
margin: 0 0 var(--s-2);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.profile__edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
.profile__edit-field .key {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.profile__edit-field input,
|
||||
.profile__edit-field textarea {
|
||||
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);
|
||||
resize: vertical;
|
||||
}
|
||||
.profile__edit-field input:focus,
|
||||
.profile__edit-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.profile__edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.profile__edit-row .meta {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.profile__edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,334 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import PostCard from "./PostCard.svelte";
|
||||
import { setMyProfile, pickAndUploadImage } from "../api/client";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
type Props = {
|
||||
handle: string;
|
||||
on_thread_click?: (uri: string) => void;
|
||||
/// DID of the authenticated user. When this matches the
|
||||
/// profile's DID, the "edit profile" button is shown; otherwise
|
||||
/// it's hidden (you can only edit your own profile).
|
||||
current_user_did?: string | null;
|
||||
};
|
||||
let { handle, on_thread_click, current_user_did }: Props = $props();
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; data: AppViewProfile };
|
||||
|
||||
type AppViewProfile = {
|
||||
did: string;
|
||||
handle: string;
|
||||
posts: AppViewPost[];
|
||||
followers: number;
|
||||
following: number;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
avatar_cid?: string;
|
||||
banner_cid?: string;
|
||||
post_count: number;
|
||||
};
|
||||
|
||||
type AppViewPost = {
|
||||
uri: string;
|
||||
did: string;
|
||||
handle: string;
|
||||
rkey: string;
|
||||
collection: string;
|
||||
text: string;
|
||||
cid: string;
|
||||
parent_uri?: string | null;
|
||||
root_uri?: string | null;
|
||||
embed?: null;
|
||||
langs: string[];
|
||||
created_at: string;
|
||||
avatar_cid?: string | null;
|
||||
};
|
||||
|
||||
let editing: boolean = $state(false);
|
||||
let viewModel: State = $state({ kind: "loading" });
|
||||
let editName: string = $state("");
|
||||
let editDesc: string = $state("");
|
||||
let editAvatarCid: string | null = $state(null);
|
||||
let saving: boolean = $state(false);
|
||||
|
||||
async function load() {
|
||||
viewModel = { kind: "loading" };
|
||||
try {
|
||||
const r = await fetch(`/api/profile/${encodeURIComponent(handle)}`);
|
||||
if (!r.ok) {
|
||||
viewModel = { kind: "error", message: `profile fetch failed: ${r.status}` };
|
||||
return;
|
||||
}
|
||||
const data: AppViewProfile = await r.json();
|
||||
viewModel = { kind: "ready", data };
|
||||
} catch (e) {
|
||||
viewModel = { kind: "error", message: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (viewModel.kind !== "ready") return;
|
||||
editName = viewModel.data.display_name ?? "";
|
||||
editDesc = viewModel.data.description ?? "";
|
||||
editAvatarCid = viewModel.data.avatar_cid ?? null;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (viewModel.kind !== "ready") return;
|
||||
saving = true;
|
||||
try {
|
||||
await setMyProfile({
|
||||
displayName: editName || undefined,
|
||||
description: editDesc || undefined,
|
||||
avatarBlobCid: editAvatarCid || undefined,
|
||||
});
|
||||
editing = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.warn("profile save failed", e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickAndUploadAvatar() {
|
||||
const blob = await pickAndUploadImage();
|
||||
if (!blob) return;
|
||||
editAvatarCid = blob.cid;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="user-profile">
|
||||
<header class="user-profile__head">
|
||||
{#if viewModel.kind === "ready"}
|
||||
<div class="user-profile__avatar">
|
||||
<Avatar
|
||||
did={viewModel.data.did}
|
||||
cid={viewModel.data.avatar_cid ?? null}
|
||||
name={viewModel.data.display_name ?? viewModel.data.handle}
|
||||
size={88}
|
||||
/>
|
||||
</div>
|
||||
<div class="user-profile__id">
|
||||
<h2 class="user-profile__name">{viewModel.data.display_name ?? "@" + viewModel.data.handle}</h2>
|
||||
<span class="user-profile__handle">@{viewModel.data.handle}</span>
|
||||
<span class="user-profile__did" title={viewModel.data.did}>{viewModel.data.did}</span>
|
||||
</div>
|
||||
<div class="user-profile__actions">
|
||||
{#if current_user_did && viewModel.data.did === current_user_did}
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() => (editing ? (editing = false) : openEdit())}
|
||||
>
|
||||
{editing ? "cancel" : "edit profile"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if viewModel.kind === "loading"}
|
||||
<div class="user-profile__loading">loading…</div>
|
||||
{:else}
|
||||
<div class="user-profile__error">err: {viewModel.message}</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if viewModel.kind === "ready" && viewModel.data.description}
|
||||
<p class="user-profile__bio">{viewModel.data.description}</p>
|
||||
{:else if viewModel.kind === "ready"}
|
||||
<!--
|
||||
Empty-state hint for a user who hasn't filled in their profile
|
||||
yet. Only shown when *no* profile fields are populated (a
|
||||
partial profile still renders whatever's there). The owner of
|
||||
an empty profile sees an explicit "set up your profile" hint
|
||||
instead of an awkward blank space.
|
||||
-->
|
||||
{#if !viewModel.data.display_name && !viewModel.data.description && !viewModel.data.avatar_cid}
|
||||
<p class="user-profile__bio user-profile__bio--empty">
|
||||
{#if current_user_did && viewModel.data.did === current_user_did}
|
||||
// no profile yet — click "edit profile" to set one up.
|
||||
{:else}
|
||||
// no profile yet.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
<div class="user-profile__edit">
|
||||
<label>
|
||||
<span class="key">display name</span>
|
||||
<input type="text" bind:value={editName} maxlength="64" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="key">description</span>
|
||||
<textarea
|
||||
bind:value={editDesc}
|
||||
rows="3"
|
||||
maxlength="300"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="user-profile__edit-avatar">
|
||||
<span class="key">avatar</span>
|
||||
<div class="user-profile__edit-avatar-row">
|
||||
{#if editAvatarCid}
|
||||
<span class="meta">cid: {editAvatarCid.slice(0, 10)}…</span>
|
||||
<button class="btn btn--ghost" type="button" onclick={() => (editAvatarCid = null)}>clear</button>
|
||||
{:else}
|
||||
<span class="meta">none</span>
|
||||
{/if}
|
||||
<button class="btn btn--ghost" type="button" onclick={pickAndUploadAvatar}>upload…</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-profile__edit-actions">
|
||||
<button class="btn btn--ghost" type="button" disabled={saving} onclick={saveProfile}>
|
||||
{saving ? "saving…" : "save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if viewModel.kind === "ready"}
|
||||
<dl class="user-profile__counts">
|
||||
<div><dt>posts</dt><dd>{viewModel.data.post_count}</dd></div>
|
||||
<div><dt>followers</dt><dd>{viewModel.data.followers}</dd></div>
|
||||
<div><dt>following</dt><dd>{viewModel.data.following}</dd></div>
|
||||
</dl>
|
||||
<div class="user-profile__posts">
|
||||
{#each viewModel.data.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={on_thread_click} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.user-profile {
|
||||
padding: 0 var(--s-3, 0.75rem);
|
||||
}
|
||||
.user-profile__head {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: var(--s-3, 0.75rem);
|
||||
align-items: center;
|
||||
padding: var(--s-3, 0.75rem) 0;
|
||||
border-bottom: 1px dashed var(--line-2, #3a3a3a);
|
||||
}
|
||||
.user-profile__avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.user-profile__id {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.user-profile__name {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 1.05rem;
|
||||
color: var(--text, #e8e8e8);
|
||||
}
|
||||
.user-profile__handle {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.8rem;
|
||||
color: var(--orange, #ff6600);
|
||||
}
|
||||
.user-profile__did {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.user-profile__bio {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text, #e8e8e8);
|
||||
padding: var(--s-3, 0.75rem) 0;
|
||||
border-bottom: 1px dashed var(--line, #2a2a2a);
|
||||
}
|
||||
.user-profile__bio--empty {
|
||||
color: var(--text-dim, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
.user-profile__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2, 0.5rem);
|
||||
padding: var(--s-3, 0.75rem) 0;
|
||||
border-bottom: 1px dashed var(--line, #2a2a2a);
|
||||
}
|
||||
.user-profile__edit label,
|
||||
.user-profile__edit-avatar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-2, 0.5rem);
|
||||
}
|
||||
.user-profile__edit .key {
|
||||
flex: 0 0 7rem;
|
||||
color: var(--orange, #ff6600);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.user-profile__edit input,
|
||||
.user-profile__edit textarea {
|
||||
flex: 1;
|
||||
background: var(--bg, #0d0d0d);
|
||||
border: 1px solid var(--line-2, #3a3a3a);
|
||||
color: var(--text, #e8e8e8);
|
||||
font-family: var(--font-mono, monospace);
|
||||
padding: var(--s-1, 0.25rem) var(--s-2, 0.5rem);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.user-profile__edit-avatar-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: var(--s-2, 0.5rem);
|
||||
align-items: center;
|
||||
}
|
||||
.user-profile__edit-avatar-row .meta {
|
||||
color: var(--text-dim, #888);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.user-profile__edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.user-profile__counts {
|
||||
display: flex;
|
||||
gap: var(--s-5, 1.5rem);
|
||||
padding: var(--s-2, 0.5rem) var(--s-3, 0.75rem);
|
||||
margin: 0;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.user-profile__counts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.user-profile__counts dt {
|
||||
color: var(--text-dim, #888);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.user-profile__counts dd {
|
||||
margin: 0;
|
||||
color: var(--text, #e8e8e8);
|
||||
font-weight: 700;
|
||||
}
|
||||
.user-profile__loading,
|
||||
.user-profile__error {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-dim, #888);
|
||||
padding: var(--s-3, 0.75rem);
|
||||
}
|
||||
.user-profile__error {
|
||||
color: var(--red, #ff3b30);
|
||||
}
|
||||
</style>
|
||||
@@ -40,6 +40,7 @@ CREATE TABLE profiles (
|
||||
following_count BIGINT NOT NULL DEFAULT 0,
|
||||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX profiles_handle_idx ON profiles (LOWER(handle));
|
||||
CREATE INDEX profiles_indexed_at_idx ON profiles (indexed_at DESC);
|
||||
|
||||
-- Backfill: seed a profile row for every handle we've already
|
||||
|
||||
Reference in New Issue
Block a user