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")]
|
#[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 +448,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))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-280
@@ -4,23 +4,18 @@
|
|||||||
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";
|
||||||
@@ -44,50 +39,6 @@
|
|||||||
let seenUris: Set<string> = new Set();
|
let seenUris: Set<string> = new Set();
|
||||||
let _statusTimer: number | undefined;
|
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.
|
// Search state.
|
||||||
let searchQuery: string = $state("");
|
let searchQuery: string = $state("");
|
||||||
let searchResults: Post[] = $state([]);
|
let searchResults: Post[] = $state([]);
|
||||||
@@ -133,7 +84,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 +257,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 +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() {
|
function scheduleSearch() {
|
||||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||||
_searchDebounce = window.setTimeout(() => {
|
_searchDebounce = window.setTimeout(() => {
|
||||||
@@ -439,7 +377,6 @@
|
|||||||
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 +388,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
|
||||||
@@ -552,156 +482,23 @@
|
|||||||
<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">
|
||||||
@@ -895,11 +692,6 @@
|
|||||||
padding: var(--s-3);
|
padding: var(--s-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--s-2);
|
|
||||||
margin: var(--s-3) 0;
|
|
||||||
}
|
|
||||||
.head {
|
.head {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
font-size: var(--fs-50);
|
||||||
@@ -972,61 +764,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);
|
||||||
@@ -1066,7 +803,6 @@
|
|||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile__h3,
|
|
||||||
.settings__h3 {
|
.settings__h3 {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
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