tauri-app: settings view + profile polish
- App.svelte: new 'settings' view with account/actions/about sections, sign-out button, pdsBase()/appviewBase() helpers, showError wired into logout, profile gains open-in-browser + sign-out + recent-posts heading - NavRail: add settings item + gear icon, View union extended to 5 - tests: NavRail.test.ts now expects 5 buttons + covers settings click, NavRailHarness View type extended
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
fetchProfile,
|
||||
fetchSearch,
|
||||
fetchPost,
|
||||
openExternalUrl,
|
||||
showError,
|
||||
type Session,
|
||||
type Post,
|
||||
type ProfileResponse,
|
||||
@@ -19,7 +21,7 @@
|
||||
import Terminal from "./lib/components/Terminal.svelte";
|
||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
|
||||
let view: View = $state("home");
|
||||
let currentUser: Session | null = $state(null);
|
||||
@@ -351,6 +353,19 @@
|
||||
await refreshTimeline(true);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await session.logout();
|
||||
view = "home";
|
||||
profile = null;
|
||||
searchResults = [];
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
userPosts = [];
|
||||
} catch (e) {
|
||||
showError(`logout failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -360,6 +375,24 @@
|
||||
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
|
||||
// talking to. Kept as plain helpers so they can be swapped for a
|
||||
// `pds_describe`/`appview_describe` Tauri command later.
|
||||
function pdsBase(): string {
|
||||
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
|
||||
return (import.meta as any).env.VITE_PDS_URL as string;
|
||||
}
|
||||
return "http://127.0.0.1:2583";
|
||||
}
|
||||
function appviewBase(): string {
|
||||
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
|
||||
return (import.meta as any).env.VITE_APPVIEW_URL as string;
|
||||
}
|
||||
return "http://127.0.0.1:2584";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !currentUser}
|
||||
@@ -446,9 +479,10 @@
|
||||
{:else if profile}
|
||||
<section class="profile">
|
||||
<header class="profile__head">
|
||||
<span class="profile__handle">{displayHandle(profile.handle)}</span>
|
||||
<span class="profile__did" title={profile.did}>{profile.did}</span>
|
||||
<div class="profile__handle">{displayHandle(profile.handle)}</div>
|
||||
<div class="profile__did" title={profile.did}>{profile.did}</div>
|
||||
</header>
|
||||
|
||||
<div class="profile__actions">
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
@@ -463,7 +497,23 @@
|
||||
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>
|
||||
@@ -478,15 +528,90 @@
|
||||
<dd>{profile.posts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if profile.posts.length === 0}
|
||||
<div class="empty">// no posts yet</div>
|
||||
<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} />
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{:else if view === "settings"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// settings</span>
|
||||
</div>
|
||||
<section class="settings">
|
||||
<h3 class="settings__h3">// account</h3>
|
||||
<dl class="settings__rows">
|
||||
<div>
|
||||
<dt>handle</dt>
|
||||
<dd>@{currentUser?.handle ?? "?"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>did</dt>
|
||||
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>posts in cache</dt>
|
||||
<dd>{userPosts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h3 class="settings__h3">// actions</h3>
|
||||
<div class="settings__actions">
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
currentUser && copyToClipboard(currentUser.did)}
|
||||
>copy my did</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openExternalUrl(
|
||||
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
|
||||
)}
|
||||
>open profile in browser</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() => (view = "home")}
|
||||
>← back to timeline</button>
|
||||
</div>
|
||||
|
||||
<h3 class="settings__h3">// about</h3>
|
||||
<dl class="settings__rows">
|
||||
<div>
|
||||
<dt>app</dt>
|
||||
<dd>maarcadetweet</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>version</dt>
|
||||
<dd>0.1.0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>backend</dt>
|
||||
<dd>{pdsBase()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>appview</dt>
|
||||
<dd>{appviewBase()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="settings__signout">
|
||||
<button
|
||||
class="btn btn--ghost btn--danger"
|
||||
type="button"
|
||||
onclick={handleLogout}
|
||||
>sign out</button>
|
||||
</div>
|
||||
</section>
|
||||
{:else if view === "search"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
@@ -753,4 +878,69 @@
|
||||
border-color: var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.profile__h3,
|
||||
.settings__h3 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
margin: var(--s-4) 0 var(--s-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.did-cell {
|
||||
word-break: break-all;
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
|
||||
.settings {
|
||||
padding: 0 var(--s-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
.settings__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
margin: 0 0 var(--s-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.settings__rows > div {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.settings__rows dt {
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 9rem;
|
||||
}
|
||||
.settings__rows dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.settings__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
margin: 0 0 var(--s-4);
|
||||
}
|
||||
.settings__signout {
|
||||
margin-top: var(--s-4);
|
||||
padding-top: var(--s-4);
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// `$bindable`, use a callback prop to bubble state changes up to
|
||||
// the parent.
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
|
||||
let {
|
||||
view = "home",
|
||||
@@ -19,6 +19,7 @@
|
||||
{ id: "compose", label: "compose", key: "c", icon: "compose" },
|
||||
{ id: "profile", label: "profile", key: "p", icon: "profile" },
|
||||
{ id: "search", label: "search", key: "/", icon: "search" },
|
||||
{ id: "settings", label: "settings", key: ",", icon: "settings" },
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -44,6 +45,11 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
|
||||
</svg>
|
||||
{:else if item.icon === "settings"}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
|
||||
|
||||
@@ -29,10 +29,10 @@ async function mountHarness() {
|
||||
}
|
||||
|
||||
describe("NavRail (callback-prop pattern)", () => {
|
||||
it("renders 4 buttons with home active", async () => {
|
||||
it("renders 5 buttons with home active", async () => {
|
||||
await mountHarness();
|
||||
const btns = target.querySelectorAll("button.rail__btn");
|
||||
expect(btns.length).toBe(4);
|
||||
expect(btns.length).toBe(5);
|
||||
expect(btns[0].classList.contains("active")).toBe(true);
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
|
||||
});
|
||||
@@ -56,5 +56,10 @@ describe("NavRail (callback-prop pattern)", () => {
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
|
||||
expect(btns[2].classList.contains("active")).toBe(true);
|
||||
|
||||
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
|
||||
expect(btns[4].classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import NavRail from "./NavRail.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
let view: View = $state("home");
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user