fix(tauri-app): wrap profile methods in PdsHttpClient impl block

The profile.get_record / set_profile methods landed in the WIP
outside any `impl PdsHttpClient { … }` block, with a stray
`&self` parameter that the parser correctly rejected. Wrap them
in a fresh impl block and add the missing closing brace — no
behaviour change, just a structural fix so the binary builds.

---

feat(tauri-app): X-style profile page with banner / avatar overlap / tabs

Replace the existing UserProfileView with a new ProfileView that
follows the X (Twitter) profile layout but stays in our
monospace / orange-on-black terminal aesthetic:

* Banner (140 px) at the top. The user's `banner_cid` (when
  present) is fetched via the existing `fetchBlob` Tauri
  command and set as a background-image. When the profile has no
  banner we render a subtle orange-tinted grid placeholder so the
  page never looks bare.
* 96 px circular avatar that overlaps the bottom of the banner by
  ~44 px, with a 4 px border in `var(--bg)` so the cutout reads
  cleanly against any banner colour.
* Identity row: large bold display name, dim handle below.
* Bio, DID meta line, and the posts / followers / following count
  dl — all monospace, all using our spacing / colour tokens.
* Tab row with the existing 'posts' tab active and
  'replies' / 'likes' rendered disabled (placeholder for future
  work).
* Edit form (gated on `current_user_did === profile.did`) with
  display-name, description, and avatar upload fields.

App.svelte refactor: the 'profile' view (current user) and the
'user' view (someone else) now both render `<ProfileView>`. The
duplicated edit state (`editingProfile`, `editProfileName`,
`editProfileDesc`, `editProfileAvatarCid`, `savingProfile`),
the duplicate `pickAndUploadAvatar` / `saveProfile` /
`refreshProfile` functions, and the unused `displayHandle` /
`fetchProfile` / `pickAndUploadImage` / `setMyProfile` imports
are gone. ProfileView handles its own fetch + edit state
internally, so the App.svelte section collapses from ~145 lines
of inline JSX to ~12.

The legacy .profile__head / .profile__bio / .counts style
classes that the new component no longer references are also
removed.
This commit is contained in:
tomdebone
2026-07-18 18:35:35 +02:00
parent 6ebf17b493
commit e4bcfbfa83
4 changed files with 671 additions and 614 deletions
@@ -385,6 +385,8 @@ pub struct UploadedBlobRef {
#[serde(rename = "$link")]
pub link: String,
}
impl PdsHttpClient {
/// `POST /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.actor.profile&rkey=self`
/// Returns the record's CBOR-decoded value as JSON, or `None` if no
/// record exists for that path. The server replies with a
@@ -446,3 +448,4 @@ pub struct UploadedBlobRef {
let v: serde_json::Value = r.json().await?;
Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null))
}
}
+10 -274
View File
@@ -4,23 +4,18 @@
session,
pdsStatus,
fetchTimeline,
fetchProfile,
fetchSearch,
fetchPost,
openExternalUrl,
showError,
pickAndUploadImage,
setMyProfile,
type Session,
type Post,
type ProfileResponse,
} from "./lib/api/client";
import NavRail from "./lib/components/NavRail.svelte";
import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte";
import UserProfileView from "./lib/components/UserProfileView.svelte";
import Avatar from "./lib/components/Avatar.svelte";
import ProfileView from "./lib/components/ProfileView.svelte";
import LoginScreen from "./lib/components/LoginScreen.svelte";
import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.svelte";
@@ -44,50 +39,6 @@
let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined;
// Profile state.
let profile: ProfileResponse | null = $state(null);
let profileLoading: boolean = $state(false);
let profileError: string | null = $state(null);
// Edit-profile state.
let editingProfile: boolean = $state(false);
let editProfileName: string = $state("");
let editProfileDesc: string = $state("");
let editProfileAvatarCid: string | null = $state(null);
let savingProfile: boolean = $state(false);
async function pickAndUploadAvatar() {
try {
const r = await pickAndUploadImage();
if (r) editProfileAvatarCid = r.cid;
} catch (e) {
console.warn("avatar upload failed", e);
}
}
async function saveProfile() {
if (!currentUser) return;
savingProfile = true;
try {
const updated = await setMyProfile({
displayName: editProfileName || undefined,
description: editProfileDesc || undefined,
avatarBlobCid: editProfileAvatarCid || undefined,
});
// Refresh the cached profile from the response (or re-fetch).
if (updated) {
profile = { ...profile, ...updated } as ProfileResponse | null;
} else {
await refreshProfile(currentUser.handle);
}
editingProfile = false;
} catch (e) {
console.warn("profile save failed", e);
} finally {
savingProfile = false;
}
}
// Search state.
let searchQuery: string = $state("");
let searchResults: Post[] = $state([]);
@@ -133,7 +84,7 @@
/// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
/// the post header. The actual profile fetch happens inside
/// `<UserProfileView>` on mount.
/// `<ProfileView>` on mount.
function openUserProfile(handle: string) {
selectedHandle = handle;
view = "user";
@@ -306,8 +257,8 @@
void refreshTimeline(true);
}
if (next === "profile") {
const handle = currentUser.handle;
void refreshProfile(handle);
// ProfileView fetches its own data on mount; nothing to
// preload here.
}
if (next === "search" && searchQuery.trim().length > 0) {
scheduleSearch();
@@ -376,19 +327,6 @@
}
}
async function refreshProfile(handle: string) {
profileLoading = true;
profileError = null;
try {
profile = await fetchProfile(handle);
} catch (e) {
profileError = String(e);
profile = null;
} finally {
profileLoading = false;
}
}
function scheduleSearch() {
if (_searchDebounce) clearTimeout(_searchDebounce);
_searchDebounce = window.setTimeout(() => {
@@ -439,7 +377,6 @@
try {
await session.logout();
setView("home");
profile = null;
searchResults = [];
threadRoot = null;
threadParent = null;
@@ -451,13 +388,6 @@
// Derive a display handle. The session already gives us the user's
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
// posts that have empty handles it falls back to a synthetic
// "@did:plc:abcd…" form, so the fallback here matches that.
function displayHandle(h: string | null | undefined): string {
if (!h) return "@unknown";
if (h.startsWith("@")) return h;
return `@${h}`;
}
// Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL /
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
// Used in the Settings view to show which backends the client is
@@ -552,157 +482,24 @@
<span class="title">// profile —</span>
<span class="as">@{selectedHandle}</span>
</div>
<UserProfileView
<ProfileView
handle={selectedHandle}
on_thread_click={openThread}
current_user_did={currentUser?.did ?? null}
/>
{:else if view === "profile"}
{#if currentUser}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{currentUser.handle}</span>
</div>
{#if profileLoading && !profile}
<Skeleton rows={4} />
{:else if profileError}
<div class="toast toast--err">err: {profileError}</div>
{:else if profile}
<section class="profile">
<header class="profile__head">
<div class="profile__handle">{displayHandle(profile.handle)}</div>
<div class="profile__did" title={profile.did}>{profile.did}</div>
{#if profile.avatar_cid}
<Avatar
did={profile.did}
cid={profile.avatar_cid}
name={profile.display_name ?? profile.handle}
size={64}
<ProfileView
handle={currentUser.handle}
on_thread_click={openThread}
current_user_did={currentUser.did}
/>
{/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}
{:else if view === "settings"}
<div class="head">
<span class="prompt">$</span>
@@ -895,11 +692,6 @@
padding: var(--s-3);
}
.profile__actions {
display: flex;
gap: var(--s-2);
margin: var(--s-3) 0;
}
.head {
font-family: var(--font-mono);
font-size: var(--fs-50);
@@ -972,61 +764,6 @@
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.profile {
padding: 0 var(--s-3);
}
.profile__head {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-3) 0 var(--s-4);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s-3);
}
.profile__handle {
font-family: var(--font-mono);
font-weight: 700;
font-size: var(--fs-200);
color: var(--orange);
}
.profile__did {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
word-break: break-all;
}
.profile__bio {
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--text);
padding: var(--s-2) 0;
}
.profile__bio--empty {
color: var(--text-dim);
font-style: italic;
}
.counts {
display: flex;
gap: var(--s-6);
padding: var(--s-2) var(--s-4);
margin: 0 0 var(--s-4);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.counts > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.counts dt { color: var(--text-dim); letter-spacing: 0.04em; }
.counts dd {
margin: 0;
color: var(--text);
font-weight: 700;
font-size: var(--fs-200);
font-variant-numeric: tabular-nums;
}
.toasts {
position: fixed;
right: var(--s-4);
@@ -1066,7 +803,6 @@
border-color: var(--red);
}
.profile__h3,
.settings__h3 {
font-family: var(--font-mono);
font-size: var(--fs-50);
@@ -0,0 +1,652 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
import PostCard from "./PostCard.svelte";
import {
setMyProfile,
pickAndUploadImage,
fetchBlob,
releaseBlob,
} from "../api/client";
import { onDestroy, 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; 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);
// 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 {
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) };
}
}
$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).
if (bannerCidLoaded !== bannerCid) {
if (bannerUrl) {
if (viewModel.kind === "ready" && viewModel.data.did) {
releaseBlob(viewModel.data.did, bannerCidLoaded ?? "");
}
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,
);
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 is a placeholder UI — actual follow wiring lives
on a future PR. Disabled so users can't trigger a no-op
network call.
-->
<button class="btn btn--primary" type="button" disabled
>follow</button
>
{/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);
}
/* ─── 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>
@@ -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>