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:
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user