Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.
* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
die jeweilige Liste inline.
Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.
Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.
Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
168 lines
6.1 KiB
Svelte
168 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
// Same shape as the bindable NavRail but with an explicit `on_select`
|
|
// callback. Mirrors the recommended fix in the task: don't rely on
|
|
// `$bindable`, use a callback prop to bubble state changes up to
|
|
// the parent.
|
|
|
|
type View =
|
|
| "home"
|
|
| "notifications"
|
|
| "compose"
|
|
| "profile"
|
|
| "user"
|
|
| "search"
|
|
| "settings";
|
|
|
|
let {
|
|
view = "home",
|
|
on_select,
|
|
unread = 0,
|
|
}: {
|
|
view?: View;
|
|
on_select?: (v: View) => void;
|
|
/// Unread-notification count from `/api/notifications/count`.
|
|
/// The parent polls it and hands it down; `0` (or anything
|
|
/// below) hides the badge entirely rather than rendering a "0".
|
|
unread?: number;
|
|
} = $props();
|
|
|
|
/// Badge label. Anything past 99 collapses to "99+" so a long
|
|
/// unread backlog can't widen the 88px rail.
|
|
const badge = $derived(unread > 99 ? "99+" : String(unread));
|
|
|
|
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
|
|
{ id: "home", label: "home", key: "g h", icon: "home" },
|
|
{ id: "notifications", label: "notifs", key: "g n", icon: "bell" },
|
|
{ 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>
|
|
|
|
<nav class="rail" aria-label="navigation">
|
|
{#each items as item}
|
|
<button
|
|
class="rail__btn"
|
|
class:active={view === item.id}
|
|
onclick={() => on_select?.(item.id)}
|
|
title={`${item.label} (${item.key})`}
|
|
aria-current={view === item.id ? "page" : undefined}
|
|
>
|
|
<span class="icon">
|
|
{#if item.icon === "home"}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
|
<path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/>
|
|
</svg>
|
|
{:else if item.icon === "bell"}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M18 15v-4a6 6 0 1 0-12 0v4l-2 3h16z"/><path d="M10 21h4"/>
|
|
</svg>
|
|
{:else if item.icon === "compose"}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
|
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">>_</text>
|
|
</svg>
|
|
{:else if item.icon === "profile"}
|
|
<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"/>
|
|
</svg>
|
|
{/if}
|
|
{#if item.id === "notifications" && unread > 0}
|
|
<!-- Live region so a screen reader announces a badge that
|
|
appears while the rail is already on screen. -->
|
|
<span
|
|
class="badge"
|
|
data-testid="notif-badge"
|
|
aria-live="polite"
|
|
aria-label={`${unread} ungelesene Benachrichtigungen`}
|
|
>{badge}</span>
|
|
{/if}
|
|
</span>
|
|
<span class="label">{item.label}</span>
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
|
|
<style>
|
|
/* Self-assign the grid area so the parent App.svelte doesn't
|
|
need a `:global(nav.rail)` selector. Doing it via a parent
|
|
child-selector is fragile under Svelte 5's scoping — the
|
|
parent's `.shell.s-XXX > nav.rail` doesn't reliably match
|
|
`<nav class="rail s-YYY">` in the Tauri webview, which leaves
|
|
the buttons invisible to clicks. Owning the grid placement
|
|
here avoids the cross-component selector entirely. */
|
|
.rail {
|
|
grid-area: rail;
|
|
width: 88px;
|
|
background: var(--bg);
|
|
border-right: 1px solid var(--line);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s-1);
|
|
padding: var(--s-3) 0;
|
|
flex-shrink: 0;
|
|
}
|
|
.rail__btn {
|
|
background: transparent;
|
|
border: 0;
|
|
color: var(--text-dim);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-50);
|
|
padding: var(--s-3) var(--s-2);
|
|
cursor: pointer;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
border-bottom: 1px solid transparent;
|
|
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
|
}
|
|
.rail__btn:hover, .rail__btn:focus-visible {
|
|
color: var(--text);
|
|
}
|
|
.rail__btn.active {
|
|
color: var(--orange);
|
|
border-bottom-color: var(--orange);
|
|
}
|
|
.icon {
|
|
width: 24px;
|
|
height: 24px;
|
|
display: grid;
|
|
place-items: center;
|
|
/* Anchor for the unread badge, which overhangs the glyph's
|
|
top-right corner the way a tray badge does. */
|
|
position: relative;
|
|
}
|
|
.badge {
|
|
position: absolute;
|
|
top: -6px;
|
|
right: -10px;
|
|
min-width: 16px;
|
|
padding: 0 4px;
|
|
border-radius: var(--r-pill);
|
|
background: var(--orange);
|
|
color: var(--bg);
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
line-height: 16px;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
font-variant-numeric: tabular-nums;
|
|
pointer-events: none;
|
|
}
|
|
.icon :global(svg) {
|
|
width: 22px;
|
|
height: 22px;
|
|
}
|
|
.label { letter-spacing: 0.04em; }
|
|
</style>
|