feat(tauri-app): profile view, Avatar component, handle navigation
UI half of the profile feature. Mirrors the previous three commits
so the user can browse and edit profiles.
* `<Avatar did cid name size>` — reusable avatar component.
Falls back to an initial-letter (or "?" when name is empty) circle
when `cid` is null. Resolves the blob through the standard PDS
fetch path so it works for any author whose PDS the client can
reach.
* `<UserProfileView handle on_thread_click current_user_did>` —
public profile page. Fetches `GET /api/profile/<handle>` on
mount, renders the avatar / display name / bio / counts /
posts. The "edit profile" button is gated on
`current_user_did === profile.did` so a user browsing
someone else's profile can't issue an unintended `setMyProfile`
against their own DID.
* `PostCard` now renders an inline `<Avatar>` + clickable handle
button that calls a new `on_handle_click` prop. The clickable
area replaces the previous dead `<a href>` (Tauri webviews
have no router).
* `App.svelte` adds an `openUserProfile(handle)` handler that
sets `selectedHandle` + `view = "user"` and mounts
`<UserProfileView>`.
* New Tauri commands `profile_get_record` / `profile_set` in
`lib.rs` + matching client helpers `getMyProfile` /
`setMyProfile` in `client.ts`. The set command sends camelCase
field names; the PDS endpoint (previous commit) round-trips them
through `#[serde(rename_all = "camelCase")]`.
* Empty-state UX for users with no profile yet (new account, or a
third-party-PDS author whose profile the AppView hasn't indexed
yet): both the current-user "profile" view and the public
"user" view render a hint ("// no profile yet — click 'edit
profile' to set one up." / "// no profile yet.") instead of a
blank bio box.
* NavRail / NavRailHarness `View` union extended with "user"
so the navigation prop type accepts the new view.
This commit is contained in:
+167
-14
@@ -9,6 +9,8 @@
|
||||
fetchPost,
|
||||
openExternalUrl,
|
||||
showError,
|
||||
pickAndUploadImage,
|
||||
setMyProfile,
|
||||
type Session,
|
||||
type Post,
|
||||
type ProfileResponse,
|
||||
@@ -17,13 +19,20 @@
|
||||
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 LoginScreen from "./lib/components/LoginScreen.svelte";
|
||||
import Terminal from "./lib/components/Terminal.svelte";
|
||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||
|
||||
let view: View = $state("home");
|
||||
// Handle for the "user" view (i.e. someone else's profile). The
|
||||
// "profile" view remains the current-user view (the NavRail icon
|
||||
// goes there). Selecting a handle (via the PostCard avatar link or
|
||||
// a future deep-link) navigates to "user" with `selectedHandle` set.
|
||||
let selectedHandle: string = $state("");
|
||||
let currentUser: Session | null = $state(null);
|
||||
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
|
||||
|
||||
@@ -40,6 +49,45 @@
|
||||
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([]);
|
||||
@@ -81,6 +129,17 @@
|
||||
threadLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
function openUserProfile(handle: string) {
|
||||
selectedHandle = handle;
|
||||
view = "user";
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
}
|
||||
function closeThread() {
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
@@ -462,14 +521,14 @@
|
||||
<div class="toast toast--err">err: {threadError}</div>
|
||||
{:else if threadRoot}
|
||||
{#if threadParent && threadParent.uri !== threadRoot.uri}
|
||||
<div class="thread-parent"><PostCard post={threadParent} /></div>
|
||||
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} /></div>
|
||||
{/if}
|
||||
<PostCard post={threadRoot} />
|
||||
<PostCard post={threadRoot} on_handle_click={openUserProfile} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#each userPosts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
{/each}
|
||||
{#if timelineCursor}
|
||||
<div class="loadmore">
|
||||
@@ -487,6 +546,17 @@
|
||||
<span class="meta">⌘↵ to post</span>
|
||||
</div>
|
||||
<ComposeBox onPosted={handlePosted} />
|
||||
{:else if view === "user"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{selectedHandle}</span>
|
||||
</div>
|
||||
<UserProfileView
|
||||
handle={selectedHandle}
|
||||
on_thread_click={openThread}
|
||||
current_user_did={currentUser?.did ?? null}
|
||||
/>
|
||||
{:else if view === "profile"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
@@ -502,8 +572,81 @@
|
||||
<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"
|
||||
@@ -555,7 +698,7 @@
|
||||
{:else}
|
||||
<h3 class="profile__h3">// recent posts</h3>
|
||||
{#each profile.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -636,15 +779,15 @@
|
||||
{:else if view === "search"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// search</span>
|
||||
<span class="title">// search —</span>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
{#if searchError}
|
||||
<div class="toast toast--err">err: {searchError}</div>
|
||||
{/if}
|
||||
@@ -657,7 +800,7 @@
|
||||
{:else}
|
||||
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
||||
{#each searchResults as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -852,6 +995,16 @@
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user