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:
+16
-280
@@ -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,156 +482,23 @@
|
||||
<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"}
|
||||
<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}
|
||||
/>
|
||||
{/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 currentUser}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
</div>
|
||||
<ProfileView
|
||||
handle={currentUser.handle}
|
||||
on_thread_click={openThread}
|
||||
current_user_did={currentUser.did}
|
||||
/>
|
||||
{/if}
|
||||
{:else if view === "settings"}
|
||||
<div class="head">
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user