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 chrono::{DateTime, TimeZone, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -31,6 +32,21 @@ pub mod types;
|
|||||||
use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse};
|
use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse};
|
||||||
|
|
||||||
pub fn router(state: AppState) -> Router {
|
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()
|
Router::new()
|
||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/api/timeline/home", get(timeline_home))
|
.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("/api/post/*uri", get(post_by_uri))
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
|
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
|
||||||
|
.layer(cors)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,13 +360,31 @@ async fn resolve_profile(
|
|||||||
// Order by `indexed_at DESC` so we get the most recent DID for
|
// 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
|
// this handle (a single user can re-use a handle if account
|
||||||
// history allows, but the latest is the active one).
|
// 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)
|
.bind(h)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ async fn post_create(
|
|||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
text: String,
|
text: String,
|
||||||
embed: Option<serde_json::Value>,
|
embed: Option<serde_json::Value>,
|
||||||
|
reply: Option<pds_client::ReplyRef>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let sess = state
|
let sess = state
|
||||||
.store
|
.store
|
||||||
@@ -130,6 +131,16 @@ async fn post_create(
|
|||||||
record["embed"] = emb;
|
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
|
let resp = state
|
||||||
.pds
|
.pds
|
||||||
.create_record(&sess.did, "app.twi.post", record, &sess.access_jwt)
|
.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]
|
#[tauri::command]
|
||||||
async fn timeline_home(
|
async fn timeline_home(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
@@ -543,8 +624,9 @@ pub fn run() {
|
|||||||
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
pds: PdsHttpClient::new(pds_url),
|
pds: PdsHttpClient::new(pds_url.clone()),
|
||||||
appview: AppViewClient::new(appview_url),
|
appview: AppViewClient::new(appview_url.clone()),
|
||||||
|
appview_url,
|
||||||
store: store::SessionStore::new(),
|
store: store::SessionStore::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -715,6 +797,8 @@ pub fn run() {
|
|||||||
unlike_post,
|
unlike_post,
|
||||||
repost_post,
|
repost_post,
|
||||||
unrepost_post,
|
unrepost_post,
|
||||||
|
follow_user,
|
||||||
|
unfollow_user,
|
||||||
status_pds,
|
status_pds,
|
||||||
fetch_blob,
|
fetch_blob,
|
||||||
pick_and_upload_image,
|
pick_and_upload_image,
|
||||||
@@ -722,6 +806,7 @@ pub fn run() {
|
|||||||
open_external_url,
|
open_external_url,
|
||||||
profile_get_record,
|
profile_get_record,
|
||||||
profile_set,
|
profile_set,
|
||||||
|
get_api_urls,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running maarcadetweet");
|
.expect("error while running maarcadetweet");
|
||||||
@@ -741,6 +826,32 @@ async fn profile_get_record(
|
|||||||
.map_err(|e| e.to_string())
|
.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]
|
#[tauri::command]
|
||||||
async fn profile_set(
|
async fn profile_set(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ pub struct CreateRecordReq {
|
|||||||
pub record: serde_json::Value,
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct CreateRecordResp {
|
pub struct CreateRecordResp {
|
||||||
pub uri: String,
|
pub uri: String,
|
||||||
@@ -385,6 +405,8 @@ pub struct UploadedBlobRef {
|
|||||||
#[serde(rename = "$link")]
|
#[serde(rename = "$link")]
|
||||||
pub link: String,
|
pub link: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PdsHttpClient {
|
||||||
/// `POST /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.actor.profile&rkey=self`
|
/// `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
|
/// Returns the record's CBOR-decoded value as JSON, or `None` if no
|
||||||
/// record exists for that path. The server replies with a
|
/// 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?;
|
let v: serde_json::Value = r.json().await?;
|
||||||
Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null))
|
Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pds: crate::pds_client::PdsHttpClient,
|
pub pds: crate::pds_client::PdsHttpClient,
|
||||||
pub appview: crate::appview_client::AppViewClient,
|
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,
|
pub store: crate::store::SessionStore,
|
||||||
}
|
}
|
||||||
|
|||||||
+339
-384
@@ -4,26 +4,22 @@
|
|||||||
session,
|
session,
|
||||||
pdsStatus,
|
pdsStatus,
|
||||||
fetchTimeline,
|
fetchTimeline,
|
||||||
fetchProfile,
|
|
||||||
fetchSearch,
|
fetchSearch,
|
||||||
fetchPost,
|
fetchPost,
|
||||||
openExternalUrl,
|
openExternalUrl,
|
||||||
showError,
|
showError,
|
||||||
pickAndUploadImage,
|
|
||||||
setMyProfile,
|
|
||||||
type Session,
|
type Session,
|
||||||
type Post,
|
type Post,
|
||||||
type ProfileResponse,
|
|
||||||
} from "./lib/api/client";
|
} from "./lib/api/client";
|
||||||
import NavRail from "./lib/components/NavRail.svelte";
|
import NavRail from "./lib/components/NavRail.svelte";
|
||||||
import StatusBar from "./lib/components/StatusBar.svelte";
|
import StatusBar from "./lib/components/StatusBar.svelte";
|
||||||
import PostCard from "./lib/components/PostCard.svelte";
|
import PostCard from "./lib/components/PostCard.svelte";
|
||||||
import ComposeBox from "./lib/components/ComposeBox.svelte";
|
import ComposeBox from "./lib/components/ComposeBox.svelte";
|
||||||
import UserProfileView from "./lib/components/UserProfileView.svelte";
|
import ProfileView from "./lib/components/ProfileView.svelte";
|
||||||
import Avatar from "./lib/components/Avatar.svelte";
|
|
||||||
import LoginScreen from "./lib/components/LoginScreen.svelte";
|
import LoginScreen from "./lib/components/LoginScreen.svelte";
|
||||||
import Terminal from "./lib/components/Terminal.svelte";
|
import Terminal from "./lib/components/Terminal.svelte";
|
||||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||||
|
import Sidebar from "./lib/components/Sidebar.svelte";
|
||||||
|
|
||||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||||
|
|
||||||
@@ -44,49 +40,16 @@
|
|||||||
let seenUris: Set<string> = new Set();
|
let seenUris: Set<string> = new Set();
|
||||||
let _statusTimer: number | undefined;
|
let _statusTimer: number | undefined;
|
||||||
|
|
||||||
// Profile state.
|
// Home tab strip — "for you" is a placeholder (no real algo yet),
|
||||||
let profile: ProfileResponse | null = $state(null);
|
// "following" is the live behavior. Mirrors the X-style "For you /
|
||||||
let profileLoading: boolean = $state(false);
|
// Following" tabs.
|
||||||
let profileError: string | null = $state(null);
|
type HomeTab = "for-you" | "following";
|
||||||
|
let homeTab: HomeTab = $state("following");
|
||||||
|
|
||||||
// Edit-profile state.
|
// Search tab strip — only "top" is wired (matches the current
|
||||||
let editingProfile: boolean = $state(false);
|
// search endpoint). The rest are visually present but disabled.
|
||||||
let editProfileName: string = $state("");
|
type SearchTab = "top" | "latest" | "people" | "photos";
|
||||||
let editProfileDesc: string = $state("");
|
let searchTab: SearchTab = $state("top");
|
||||||
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 state.
|
// Search state.
|
||||||
let searchQuery: string = $state("");
|
let searchQuery: string = $state("");
|
||||||
@@ -101,6 +64,28 @@
|
|||||||
let threadLoading: boolean = $state(false);
|
let threadLoading: boolean = $state(false);
|
||||||
let threadError: string | null = $state(null);
|
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`
|
// Toasts surfaced by child components via the `maarcadetweet:toast`
|
||||||
// window event. We keep the last few so a slow render doesn't
|
// window event. We keep the last few so a slow render doesn't
|
||||||
// wipe the message before the user reads it.
|
// wipe the message before the user reads it.
|
||||||
@@ -133,7 +118,7 @@
|
|||||||
/// Navigate to the "user" profile view for `handle`. Called from
|
/// Navigate to the "user" profile view for `handle`. Called from
|
||||||
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
|
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
|
||||||
/// the post header. The actual profile fetch happens inside
|
/// the post header. The actual profile fetch happens inside
|
||||||
/// `<UserProfileView>` on mount.
|
/// `<ProfileView>` on mount.
|
||||||
function openUserProfile(handle: string) {
|
function openUserProfile(handle: string) {
|
||||||
selectedHandle = handle;
|
selectedHandle = handle;
|
||||||
view = "user";
|
view = "user";
|
||||||
@@ -306,8 +291,8 @@
|
|||||||
void refreshTimeline(true);
|
void refreshTimeline(true);
|
||||||
}
|
}
|
||||||
if (next === "profile") {
|
if (next === "profile") {
|
||||||
const handle = currentUser.handle;
|
// ProfileView fetches its own data on mount; nothing to
|
||||||
void refreshProfile(handle);
|
// preload here.
|
||||||
}
|
}
|
||||||
if (next === "search" && searchQuery.trim().length > 0) {
|
if (next === "search" && searchQuery.trim().length > 0) {
|
||||||
scheduleSearch();
|
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() {
|
function scheduleSearch() {
|
||||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||||
_searchDebounce = window.setTimeout(() => {
|
_searchDebounce = window.setTimeout(() => {
|
||||||
@@ -431,15 +403,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePosted() {
|
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);
|
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() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
await session.logout();
|
await session.logout();
|
||||||
setView("home");
|
setView("home");
|
||||||
profile = null;
|
|
||||||
searchResults = [];
|
searchResults = [];
|
||||||
threadRoot = null;
|
threadRoot = null;
|
||||||
threadParent = null;
|
threadParent = null;
|
||||||
@@ -451,13 +438,6 @@
|
|||||||
// Derive a display handle. The session already gives us the user's
|
// Derive a display handle. The session already gives us the user's
|
||||||
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
||||||
// posts that have empty handles it falls back to a synthetic
|
// 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 /
|
// Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL /
|
||||||
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
|
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
|
||||||
// Used in the Settings view to show which backends the client is
|
// Used in the Settings view to show which backends the client is
|
||||||
@@ -493,6 +473,7 @@
|
|||||||
on_select={(v) => setView(v)}
|
on_select={(v) => setView(v)}
|
||||||
/>
|
/>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
<div class="main-inner">
|
||||||
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
||||||
{#if view === "home"}
|
{#if view === "home"}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -501,6 +482,20 @@
|
|||||||
<span class="as">@{currentUser.handle}</span>
|
<span class="as">@{currentUser.handle}</span>
|
||||||
<span class="meta">→ {userPosts.length} posts · polling every 5s</span>
|
<span class="meta">→ {userPosts.length} posts · polling every 5s</span>
|
||||||
</div>
|
</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}
|
{#if timelineError}
|
||||||
<div class="toast toast--err">err: {timelineError}</div>
|
<div class="toast toast--err">err: {timelineError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -521,14 +516,14 @@
|
|||||||
<div class="toast toast--err">err: {threadError}</div>
|
<div class="toast toast--err">err: {threadError}</div>
|
||||||
{:else if threadRoot}
|
{:else if threadRoot}
|
||||||
{#if threadParent && threadParent.uri !== threadRoot.uri}
|
{#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}
|
{/if}
|
||||||
<PostCard post={threadRoot} on_handle_click={openUserProfile} />
|
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each userPosts as p (p.uri)}
|
{#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}
|
{/each}
|
||||||
{#if timelineCursor}
|
{#if timelineCursor}
|
||||||
<div class="loadmore">
|
<div class="loadmore">
|
||||||
@@ -545,235 +540,125 @@
|
|||||||
<span class="as">@{currentUser.handle}</span>
|
<span class="as">@{currentUser.handle}</span>
|
||||||
<span class="meta">⌘↵ to post</span>
|
<span class="meta">⌘↵ to post</span>
|
||||||
</div>
|
</div>
|
||||||
<ComposeBox onPosted={handlePosted} />
|
<ComposeBox
|
||||||
|
onPosted={handlePosted}
|
||||||
|
replyTo={replyTo}
|
||||||
|
onClearReply={clearReply}
|
||||||
|
/>
|
||||||
{:else if view === "user"}
|
{:else if view === "user"}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="prompt">$</span>
|
<span class="prompt">$</span>
|
||||||
<span class="title">// profile —</span>
|
<span class="title">// profile —</span>
|
||||||
<span class="as">@{selectedHandle}</span>
|
<span class="as">@{selectedHandle}</span>
|
||||||
</div>
|
</div>
|
||||||
<UserProfileView
|
<ProfileView
|
||||||
handle={selectedHandle}
|
handle={selectedHandle}
|
||||||
on_thread_click={openThread}
|
on_thread_click={openThread}
|
||||||
current_user_did={currentUser?.did ?? null}
|
current_user_did={currentUser?.did ?? null}
|
||||||
/>
|
/>
|
||||||
{:else if view === "profile"}
|
{:else if view === "profile"}
|
||||||
<div class="head">
|
{#if currentUser}
|
||||||
<span class="prompt">$</span>
|
<div class="head">
|
||||||
<span class="title">// profile —</span>
|
<span class="prompt">$</span>
|
||||||
<span class="as">@{currentUser.handle}</span>
|
<span class="title">// profile —</span>
|
||||||
</div>
|
<span class="as">@{currentUser.handle}</span>
|
||||||
{#if profileLoading && !profile}
|
</div>
|
||||||
<Skeleton rows={4} />
|
<ProfileView
|
||||||
{:else if profileError}
|
handle={currentUser.handle}
|
||||||
<div class="toast toast--err">err: {profileError}</div>
|
on_thread_click={openThread}
|
||||||
{:else if profile}
|
current_user_did={currentUser.did}
|
||||||
<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}
|
{/if}
|
||||||
{:else if view === "settings"}
|
{:else if view === "settings"}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="prompt">$</span>
|
<span class="prompt">$</span>
|
||||||
<span class="title">// settings</span>
|
<span class="title">// settings</span>
|
||||||
|
<span class="meta">@{currentUser?.handle ?? "?"}</span>
|
||||||
</div>
|
</div>
|
||||||
<section class="settings">
|
<section class="settings">
|
||||||
<h3 class="settings__h3">// account</h3>
|
<!-- Account — X-style rows: label left, value right, full-width clickable -->
|
||||||
<dl class="settings__rows">
|
<div class="settings__group">
|
||||||
<div>
|
<h3 class="settings__h3">// account</h3>
|
||||||
<dt>handle</dt>
|
<div class="settings__list">
|
||||||
<dd>@{currentUser?.handle ?? "?"}</dd>
|
<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>
|
||||||
<div>
|
<div class="settings__actions">
|
||||||
<dt>did</dt>
|
<button
|
||||||
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
|
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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<h3 class="settings__h3">// about</h3>
|
<!-- Backend / connection info — same row pattern -->
|
||||||
<dl class="settings__rows">
|
<div class="settings__group">
|
||||||
<div>
|
<h3 class="settings__h3">// backend</h3>
|
||||||
<dt>app</dt>
|
<div class="settings__list">
|
||||||
<dd>maarcadetweet</dd>
|
<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>
|
||||||
<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 class="settings__signout">
|
<!-- Sign-out — separate danger zone at the bottom, like X's "Log out" row -->
|
||||||
<button
|
<div class="settings__group settings__group--danger">
|
||||||
class="btn btn--ghost btn--danger"
|
<div class="settings__list">
|
||||||
type="button"
|
<button
|
||||||
onclick={handleLogout}
|
class="settings__action settings__action--danger"
|
||||||
>sign out</button>
|
type="button"
|
||||||
|
onclick={handleLogout}
|
||||||
|
>
|
||||||
|
<span>sign out</span>
|
||||||
|
<span class="settings__action-hint">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if view === "search"}
|
{:else if view === "search"}
|
||||||
@@ -788,6 +673,32 @@
|
|||||||
placeholder="grep posts…"
|
placeholder="grep posts…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
{#if searchError}
|
||||||
<div class="toast toast--err">err: {searchError}</div>
|
<div class="toast toast--err">err: {searchError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -800,11 +711,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
||||||
{#each searchResults as p (p.uri)}
|
{#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}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</Terminal>
|
</Terminal>
|
||||||
|
{#if view === "home"}
|
||||||
|
<Sidebar posts={userPosts} onSearch={onSidebarSearch} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
|
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
|
||||||
</div>
|
</div>
|
||||||
@@ -894,12 +809,51 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: var(--s-3);
|
padding: var(--s-3);
|
||||||
}
|
}
|
||||||
|
.main-inner {
|
||||||
.profile__actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s-2);
|
gap: var(--s-3);
|
||||||
margin: var(--s-3) 0;
|
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 {
|
.head {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
font-size: var(--fs-50);
|
||||||
@@ -972,61 +926,6 @@
|
|||||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
.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 {
|
.toasts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: var(--s-4);
|
right: var(--s-4);
|
||||||
@@ -1056,68 +955,124 @@
|
|||||||
background: rgba(255, 59, 48, 0.08);
|
background: rgba(255, 59, 48, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--danger {
|
/* (the legacy .btn--danger class used to be applied to the
|
||||||
color: var(--red);
|
"sign out" button — that's now styled via
|
||||||
border-color: var(--red);
|
`.settings__group--danger .settings__action` which is its own
|
||||||
}
|
selector tree in the settings section below) */
|
||||||
.btn--danger:hover:not(:disabled) {
|
|
||||||
background: rgba(255, 59, 48, 0.08);
|
|
||||||
color: var(--red);
|
|
||||||
border-color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.settings__h3 {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
font-size: var(--fs-50);
|
||||||
color: var(--text-dim);
|
color: var(--orange);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: var(--tracking-label);
|
||||||
margin: var(--s-4) 0 var(--s-2);
|
margin: 0;
|
||||||
font-weight: 400;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.settings__list {
|
||||||
.did-cell {
|
|
||||||
word-break: break-all;
|
|
||||||
font-size: var(--fs-50);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings {
|
|
||||||
padding: 0 var(--s-3);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: var(--s-1);
|
justify-content: space-between;
|
||||||
padding: var(--s-2) var(--s-4);
|
gap: var(--s-3);
|
||||||
margin: 0 0 var(--s-4);
|
padding: var(--s-3) var(--s-4);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
font-size: var(--fs-50);
|
||||||
}
|
}
|
||||||
.settings__rows > div {
|
.settings__list .settings__row:last-child {
|
||||||
display: flex;
|
border-bottom: 0;
|
||||||
gap: var(--s-3);
|
|
||||||
}
|
}
|
||||||
.settings__rows dt {
|
.settings__label {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: var(--tracking-label);
|
||||||
min-width: 9rem;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.settings__rows dd {
|
.settings__value {
|
||||||
margin: 0;
|
|
||||||
color: var(--text);
|
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 {
|
.settings__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: var(--s-2);
|
background: var(--bg-elev);
|
||||||
margin: 0 0 var(--s-4);
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.settings__signout {
|
.settings__action {
|
||||||
margin-top: var(--s-4);
|
display: flex;
|
||||||
padding-top: var(--s-4);
|
align-items: center;
|
||||||
border-top: 1px dashed var(--line);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -24,6 +24,33 @@ async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unkn
|
|||||||
return invoke<T>(cmd, args);
|
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
|
* Strict variant of `tauriCall` for actions that MUST hit the
|
||||||
* Tauri runtime (login, register, logout, post, like, etc.). In
|
* Tauri runtime (login, register, logout, post, like, etc.). In
|
||||||
@@ -191,6 +218,8 @@ export type Post = {
|
|||||||
embed?: Embed | null;
|
embed?: Embed | null;
|
||||||
langs: string[];
|
langs: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
like_count?: number;
|
||||||
|
repost_count?: number;
|
||||||
/// Resolved author-avatar CID from the AppView's `profiles`
|
/// Resolved author-avatar CID from the AppView's `profiles`
|
||||||
/// cache. NULL when the user has no profile record yet.
|
/// cache. NULL when the user has no profile record yet.
|
||||||
avatar_cid?: string | null;
|
avatar_cid?: string | null;
|
||||||
@@ -236,9 +265,20 @@ export type ThreadResponse = {
|
|||||||
repost_count?: number;
|
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(
|
export async function createPost(
|
||||||
text: string,
|
text: string,
|
||||||
embed?: unknown | null,
|
embed?: unknown | null,
|
||||||
|
reply?: ReplyRef | null,
|
||||||
): Promise<Post> {
|
): Promise<Post> {
|
||||||
// The Rust post_create command returns a different shape (uri+cid
|
// 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
|
// 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
|
// `embed` is forwarded verbatim; the caller is responsible for
|
||||||
// shaping it as an `app.bsky.embed.images` / `.external` / etc.
|
// shaping it as an `app.bsky.embed.images` / `.external` / etc.
|
||||||
// record. Pass `null` or `undefined` to omit.
|
// 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", {
|
return await safeInvoke<any>("post_create", {
|
||||||
text,
|
text,
|
||||||
embed: embed ?? null,
|
embed: embed ?? null,
|
||||||
|
reply: reply ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,10 +421,28 @@ export async function unrepostPost(
|
|||||||
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget user-visible error toast. Implemented as a
|
/// `followUser(targetDid)` — create an `app.bsky.graph.follow` record
|
||||||
/// `window` `CustomEvent` so any component can show errors without
|
/// on the user's PDS. Returns `{ uri, cid }` — the client caches
|
||||||
/// pulling in a global store. `App.svelte` listens for the event
|
/// `uri` in localStorage so `unfollowUser(uri)` can delete the
|
||||||
/// and renders the toast UI.
|
/// 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 {
|
export function showError(text: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.dispatchEvent(
|
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 a native OS notification. Thin wrapper around the
|
||||||
/// `show_notification` Tauri command. The Rust side also emits an
|
/// `show_notification` Tauri command. The Rust side also emits an
|
||||||
/// `app://notification` event with the same payload, so the click
|
/// `app://notification` event with the same payload, so the click
|
||||||
|
|||||||
@@ -8,27 +8,37 @@
|
|||||||
releaseBlob,
|
releaseBlob,
|
||||||
session,
|
session,
|
||||||
type Post,
|
type Post,
|
||||||
|
type Session,
|
||||||
|
type ReplyRef,
|
||||||
} from "../api/client";
|
} 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;
|
const MAX = 160;
|
||||||
let { onPosted }: { onPosted?: () => void } = $props();
|
let { onPosted, replyTo = null, onClearReply }: Props = $props();
|
||||||
let text: string = $state("");
|
let text = $state("");
|
||||||
let isPosting: boolean = $state(false);
|
let isPosting = $state(false);
|
||||||
let isAttaching: boolean = $state(false);
|
let isAttaching = $state(false);
|
||||||
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null);
|
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(() => {
|
$effect(() => {
|
||||||
const u = $session;
|
currentUser = $session;
|
||||||
did = u?.did ?? "";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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: {
|
let attachment: {
|
||||||
cid: string;
|
cid: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
@@ -36,267 +46,417 @@
|
|||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
} | null = $state(null);
|
} | null = $state(null);
|
||||||
|
|
||||||
let remaining = $derived(MAX - text.length);
|
// Count graphemes, not UTF-16 code units. atproto enforces
|
||||||
let counterClass = $derived(
|
// `maxLength: 160` as graphemes, so a single 🚀 (surrogate pair)
|
||||||
remaining < 0 ? "counter counter--err" :
|
// must count as 1, not 2. `Intl.Segmenter` is built into the
|
||||||
remaining < 40 ? "counter counter--warn" : "counter"
|
// 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) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
post();
|
void post();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBytes(n: number): string {
|
function fmtBytes(bytes: number) {
|
||||||
if (n < 1024) return `${n} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attach() {
|
async function attach() {
|
||||||
if (isAttaching || attachment) return;
|
if (isAttaching || attachment) return;
|
||||||
if (!did) {
|
if (!currentUser?.did) {
|
||||||
status = { kind: "err", msg: "> log in first" };
|
status = { kind: "err", msg: "log in to add an image" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isAttaching = true;
|
isAttaching = true;
|
||||||
status = { kind: "info", msg: "> picking…" };
|
status = null;
|
||||||
try {
|
try {
|
||||||
const blob = await pickAndUploadImage();
|
const blob = await pickAndUploadImage();
|
||||||
if (!blob) {
|
if (!blob) return;
|
||||||
// User cancelled — restore the previous status rather than
|
const previewUrl = await fetchBlob(currentUser.did, blob.cid);
|
||||||
// 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);
|
|
||||||
attachment = { ...blob, previewUrl };
|
attachment = { ...blob, previewUrl };
|
||||||
status = { kind: "info", msg: `> attached (${fmtBytes(blob.size)})` };
|
} catch (error) {
|
||||||
} catch (e) {
|
status = { kind: "err", msg: String(error) };
|
||||||
status = { kind: "err", msg: `> ${String(e)}` };
|
|
||||||
} finally {
|
} finally {
|
||||||
isAttaching = false;
|
isAttaching = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAttachment() {
|
function removeAttachment() {
|
||||||
if (attachment) {
|
if (!attachment || !currentUser?.did) return;
|
||||||
// Revoke the object URL. `fetchBlob` may have evicted the
|
releaseBlob(currentUser.did, attachment.cid);
|
||||||
// cache entry for a different reason, so tolerate a no-op.
|
attachment = null;
|
||||||
// The user can re-attach — the next fetch will allocate a
|
|
||||||
// fresh URL.
|
|
||||||
releaseBlob(did, attachment.cid);
|
|
||||||
attachment = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post() {
|
async function post() {
|
||||||
if (!text.trim() || remaining < 0 || isPosting) return;
|
if (!canPost) return;
|
||||||
isPosting = true;
|
isPosting = true;
|
||||||
status = { kind: "info", msg: "> posting…" };
|
status = { kind: "info", msg: "posting…" };
|
||||||
try {
|
try {
|
||||||
const embed = attachment ? makeImagesEmbed(attachment) : null;
|
const embed = attachment ? makeImagesEmbed(attachment) : null;
|
||||||
const r: Post = await createPost(text, embed);
|
const reply: ReplyRef | null = replyTo
|
||||||
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` };
|
? { 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 = "";
|
text = "";
|
||||||
removeAttachment();
|
removeAttachment();
|
||||||
onPosted?.();
|
onPosted?.();
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
status = { kind: "err", msg: `> ${String(e)}` };
|
status = { kind: "err", msg: String(error) };
|
||||||
showError(`post failed: ${e}`);
|
showError(`post failed: ${error}`);
|
||||||
} finally {
|
} finally {
|
||||||
isPosting = false;
|
isPosting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="compose">
|
<section class="compose" aria-label={replyTo ? `Reply to @${replyTo.handle}` : "Compose a post"}>
|
||||||
<div class="compose__head">
|
<div class="compose__avatar">
|
||||||
<span class="title">// compose</span>
|
<Avatar
|
||||||
<span class="handle">@you</span>
|
did={currentUser?.did ?? ""}
|
||||||
<span class={counterClass}>{remaining}</span>
|
name={currentUser?.handle ?? "you"}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
</div>
|
</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
|
<textarea
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
placeholder="// what's happening in 160 chars?"
|
placeholder={replyTo ? `Reply to @${replyTo.handle}…` : "What's happening?"}
|
||||||
rows="3"
|
rows="3"
|
||||||
maxlength="500"
|
maxlength={MAX}
|
||||||
|
aria-label="Post text"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
|
||||||
{#if attachment}
|
{#if attachment}
|
||||||
<div class="compose__attach">
|
<div class="attachment">
|
||||||
<img
|
<img src={attachment.previewUrl} alt="Attachment preview" />
|
||||||
class="compose__preview"
|
<div class="attachment__meta">
|
||||||
src={attachment.previewUrl}
|
<span>{attachment.mimeType}</span>
|
||||||
alt="attachment preview"
|
<span>{fmtBytes(attachment.size)}</span>
|
||||||
/>
|
</div>
|
||||||
<div class="compose__attach-meta">
|
<button
|
||||||
<span class="compose__attach-cid" title={attachment.cid}>cid: {attachment.cid.slice(0, 10)}…</span>
|
type="button"
|
||||||
<span class="compose__attach-mime">{attachment.mimeType}</span>
|
class="attachment__remove"
|
||||||
<span class="compose__attach-size">{fmtBytes(attachment.size)}</span>
|
onclick={removeAttachment}
|
||||||
|
disabled={isPosting}
|
||||||
|
title="remove image"
|
||||||
|
aria-label="Remove image"
|
||||||
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="compose__footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="compose__attach-remove"
|
class="media-button"
|
||||||
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"
|
|
||||||
onclick={attach}
|
onclick={attach}
|
||||||
disabled={isAttaching || !!attachment || isPosting}
|
disabled={isAttaching || !!attachment || isPosting}
|
||||||
title={attachment ? "image already attached" : "attach image"}
|
title={attachment ? "one image already attached" : "add image"}
|
||||||
>
|
>
|
||||||
{isAttaching ? "picking…" : "📎"}
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
</button>
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||||
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button>
|
<circle cx="8.5" cy="9" r="1.5" />
|
||||||
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}>
|
<path d="m4 17 5-5 4 4 3-3 4 4" />
|
||||||
{isPosting ? "posting…" : "post"}
|
</svg>
|
||||||
|
<span>{isAttaching ? "adding…" : "image"}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{#if status}
|
||||||
|
<div class="status status--{status.kind}" role="status">{status.msg}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if status}
|
</section>
|
||||||
<div class="status status--{status.kind}">{status.msg}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.compose {
|
.compose {
|
||||||
background: var(--bg-elev);
|
display: grid;
|
||||||
border: 1px solid var(--line-2);
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
border-radius: var(--r-md);
|
|
||||||
margin: var(--s-4) var(--s-5);
|
|
||||||
}
|
|
||||||
.compose__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
padding: var(--s-2) var(--s-4);
|
padding: var(--s-4);
|
||||||
background: var(--bg-deep);
|
|
||||||
border-bottom: 1px solid var(--line);
|
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);
|
background: var(--bg-deep);
|
||||||
border-top: 1px dashed var(--line);
|
|
||||||
}
|
}
|
||||||
.compose__preview {
|
|
||||||
width: 64px;
|
.compose__avatar {
|
||||||
height: 64px;
|
padding-top: 2px;
|
||||||
object-fit: cover;
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
border: 1px solid var(--line-2);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
}
|
||||||
.compose__attach-meta {
|
|
||||||
display: flex;
|
.compose__content {
|
||||||
flex-direction: column;
|
min-width: 0;
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-50);
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
}
|
||||||
.compose__attach-cid { color: var(--cid-fg); }
|
|
||||||
.compose__attach-mime,
|
.replying {
|
||||||
.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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--s-2) var(--s-4);
|
margin-bottom: var(--s-2);
|
||||||
border-top: 1px solid var(--line);
|
color: var(--text-dim);
|
||||||
}
|
|
||||||
.hint { font-family: var(--font-mono); font-size: var(--fs-50); color: var(--text-dim); }
|
|
||||||
.actions { display: flex; gap: var(--s-2); }
|
|
||||||
.btn {
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
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;
|
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); }
|
.replying button:hover {
|
||||||
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
|
background: var(--orange-8);
|
||||||
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
|
color: var(--orange);
|
||||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
}
|
||||||
.status {
|
|
||||||
|
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-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
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);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.status--ok { color: var(--green); }
|
|
||||||
.status--err { color: var(--red); }
|
.compose__submit {
|
||||||
.status--info { color: var(--orange); }
|
gap: var(--s-3);
|
||||||
</style>
|
}
|
||||||
|
|
||||||
|
.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 { 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 handle: string = $state("");
|
||||||
let password: string = $state("");
|
let password: string = $state("");
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
@@ -42,47 +45,46 @@
|
|||||||
<div class="t">maarcadetweet — {mode}</div>
|
<div class="t">maarcadetweet — {mode}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-body">
|
<div class="terminal-body">
|
||||||
<div class="line">
|
<h1 class="brand">maarcadetweet</h1>
|
||||||
<span class="prompt">$</span> maarcadetweet {mode}
|
<p class="tagline">// the timeline that fits in 160 chars.</p>
|
||||||
</div>
|
|
||||||
<div class="line muted">// the timeline that fits in 160 chars.</div>
|
|
||||||
<div class="line"> </div>
|
|
||||||
{#if serverInfo}
|
{#if serverInfo}
|
||||||
<div class="line muted">// pds: {serverInfo.did ?? "?"}</div>
|
<div class="meta">
|
||||||
<div class="line muted">// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</div>
|
<span>// pds: {serverInfo.did ?? "?"}</span>
|
||||||
|
<span>// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</span>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="line"> </div>
|
<form class="form" onsubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||||
<div class="form">
|
<label class="field">
|
||||||
<label>
|
<span class="key">handle</span>
|
||||||
<span class="key">handle:</span>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={handle}
|
bind:value={handle}
|
||||||
placeholder="alice.maarcadetweet.local"
|
placeholder="alice.maarcadetweet.local"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
autocomplete="username"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="field">
|
||||||
<span class="key">password:</span>
|
<span class="key">password</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
placeholder="≥ 8 chars"
|
placeholder="≥ 8 chars"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
onkeydown={(e) => e.key === "Enter" && submit()}
|
||||||
|
autocomplete={mode === "register" ? "new-password" : "current-password"}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</form>
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="line err">error: {error}</div>
|
<div class="err">err: {error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="line"> </div>
|
<div class="actions">
|
||||||
<div class="line">
|
|
||||||
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
|
<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>
|
||||||
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +96,8 @@
|
|||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--line-2);
|
||||||
border-radius: var(--r-lg);
|
border-radius: var(--r-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: min(560px, 92vw);
|
width: min(480px, 92vw);
|
||||||
|
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
.terminal-head {
|
.terminal-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -121,49 +124,109 @@
|
|||||||
}
|
}
|
||||||
.terminal-body {
|
.terminal-body {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.95rem;
|
padding: var(--s-6) var(--s-5);
|
||||||
line-height: 1.85;
|
display: flex;
|
||||||
padding: var(--s-5);
|
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 {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
margin: var(--s-4) 0;
|
margin-top: var(--s-3);
|
||||||
}
|
}
|
||||||
.form label {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: var(--s-3);
|
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 {
|
.form input {
|
||||||
flex: 1;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--line-2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-100);
|
||||||
padding: var(--s-2) var(--s-3);
|
padding: var(--s-2) var(--s-3);
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: border-color var(--dur) var(--ease);
|
||||||
}
|
}
|
||||||
.form input:focus { border-color: var(--orange); }
|
.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-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
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-radius: var(--r-sm);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
cursor: pointer;
|
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--primary:hover:not(:disabled) { background: var(--orange-bright); }
|
||||||
.btn--ghost { background: transparent; color: var(--text-dim); border-color: var(--line-2); }
|
.btn--ghost {
|
||||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
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; }
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
</style>
|
</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,
|
following_count BIGINT NOT NULL DEFAULT 0,
|
||||||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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);
|
CREATE INDEX profiles_indexed_at_idx ON profiles (indexed_at DESC);
|
||||||
|
|
||||||
-- Backfill: seed a profile row for every handle we've already
|
-- Backfill: seed a profile row for every handle we've already
|
||||||
|
|||||||
Reference in New Issue
Block a user