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:
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { fetchBlob } from "../api/client";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
type Props = {
|
||||
did: string;
|
||||
/** Blob-ref `$link` from a posts/post record or a profile
|
||||
* record. The Avatar component resolves the (did, cid) pair via
|
||||
* the existing PDS `getBlob` route through `fetchBlob`. NULL
|
||||
* falls back to the deterministic initial-letter SVG. */
|
||||
cid?: string | null;
|
||||
/** Human-readable name used for the initial-letter fallback and
|
||||
* the alt text. */
|
||||
name?: string | null;
|
||||
/** Pixel size. The same component is used at 24 px (PostCard),
|
||||
* 32 px (Header current-user avatar) and 88 px (Profile-View
|
||||
* header). */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
let { did, cid = null, name = "", size = 32 }: Props = $props();
|
||||
const initial = $derived(
|
||||
((name || "").trim()[0] || "?").toUpperCase(),
|
||||
);
|
||||
let blobUrl: string | null = $state(null);
|
||||
let lastCid: string | null = null;
|
||||
|
||||
$effect(() => {
|
||||
// Drop the previous blob URL when the CID changes — keeps the
|
||||
// in-memory cache (managed by `fetchBlob`) lean and avoids leaking
|
||||
// object URLs across navigations.
|
||||
if (lastCid !== cid) {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
blobUrl = null;
|
||||
lastCid = cid;
|
||||
}
|
||||
if (!cid) return;
|
||||
let cancelled = false;
|
||||
fetchBlob(did, cid)
|
||||
.then((u) => {
|
||||
if (!cancelled) blobUrl = u;
|
||||
else URL.revokeObjectURL(u);
|
||||
})
|
||||
.catch(() => {
|
||||
/* Fall back to the initial letter on fetch error. */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if blobUrl}
|
||||
<img
|
||||
class="avatar"
|
||||
style:width="{size}px"
|
||||
style:height="{size}px"
|
||||
src={blobUrl}
|
||||
alt={name ? `${name}'s avatar` : "avatar"}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="avatar avatar--fallback"
|
||||
style:width="{size}px"
|
||||
style:height="{size}px"
|
||||
style:font-size="{Math.max(10, Math.floor(size * 0.45))}px"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.avatar {
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elev, #1a1a1a);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar--fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-dim, #888);
|
||||
border: 1px solid var(--line-2, #3a3a3a);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user