give the soulseek workspace a url
The section is /soulseek/:section — nav entries are NavLinks, the view panel reads the same
URL instead of being told, and the dashboard's tiles and recent searches are real links (a
recent search now opens that search, not the search screen's front page).
The peer went in `?user=<name>` rather than the /soulseek/users/:name the audit sketched: a
second path segment would need a nested route just to keep the nav highlight, and `?search=`
had already set the convention there. That deletes the `soulseek:user` channel and with it a
`{username, nonce}` request the Users panel consumed-once and cleared — the nonce existed so
asking for the same peer twice counted twice. A link is idempotent, so there is nothing to
consume and nothing to disambiguate.
`soulseek:refresh` stays: it is a signal, which is what channels are for.
This commit is contained in:
@@ -59,6 +59,7 @@ export function App() {
|
||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Navigate, useParams } from 'react-router';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulseekSection } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /soulseek uses the Workspace/Panel system (like /chat and /music): a section nav (soulseek-nav) on
|
||||
// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section'
|
||||
// channel. Both talk to slskd through the /api/slskd auth proxy.
|
||||
// /soulseek uses the Workspace/Panel system (like /photos): a section nav (soulseek-nav) on the left and
|
||||
// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection —
|
||||
// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy.
|
||||
|
||||
export const SoulseekScreen = () => {
|
||||
const { section } = useParams();
|
||||
// v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
|
||||
// layout doesn't resurrect as two mismatched panels.
|
||||
const workspace = useDashboardState<LayoutNode>('screens/soulseek-v2', defaultLayout);
|
||||
|
||||
// After the hooks — it returns early.
|
||||
if (!isSoulseekSection(section)) return <Navigate to={soulseekSectionPath(DEFAULT_SOULSEEK_SECTION)} replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { ArrowDownToLine, ArrowUpFromLine, Search, RefreshCw, Radio } from 'lucide-react';
|
||||
import {
|
||||
SOULSEEK_SECTION_CHANNEL,
|
||||
SEARCH_PARAM,
|
||||
formatWhen,
|
||||
soulseekSectionPath,
|
||||
transferPhase,
|
||||
type SlskdApplication,
|
||||
type SlskdSearchSummary,
|
||||
type SlskdTransferUser,
|
||||
type SoulseekSectionId,
|
||||
type TransferPhase,
|
||||
} from './shared';
|
||||
|
||||
// Dashboard — the at-a-glance overview for the /soulseek workspace: connection health, live
|
||||
// download/upload tallies, and the most recent searches. Read-only aggregation over the same slskd
|
||||
// endpoints the other panels use; tiles publish to the section channel so clicking one jumps the view.
|
||||
// endpoints the other panels use; every tile and row is a real link into the section it summarises, and
|
||||
// a recent search links to that search rather than to the search screen's front page.
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
@@ -29,7 +30,6 @@ const tally = (users: SlskdTransferUser[]): Tally => {
|
||||
|
||||
export const SoulseekDashboard = () => {
|
||||
const client = useClient();
|
||||
const [, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
||||
const [app, setApp] = useState<SlskdApplication | null>(null);
|
||||
const [downloads, setDownloads] = useState<Tally>(emptyTally);
|
||||
const [uploads, setUploads] = useState<Tally>(emptyTally);
|
||||
@@ -85,7 +85,7 @@ export const SoulseekDashboard = () => {
|
||||
</div>
|
||||
|
||||
{/* Downloads */}
|
||||
<Section title="Downloads" icon={<ArrowDownToLine className="h-4 w-4" />} onClick={() => setSection('downloads')}>
|
||||
<Section title="Downloads" icon={<ArrowDownToLine className="h-4 w-4" />} to={soulseekSectionPath('downloads')}>
|
||||
<Tile label="Downloading" value={downloads.downloading} dot="bg-blue-500" />
|
||||
<Tile label="Queued" value={downloads.queued} dot="bg-amber-500" />
|
||||
<Tile label="Completed" value={downloads.done} dot="bg-green-500" />
|
||||
@@ -93,7 +93,7 @@ export const SoulseekDashboard = () => {
|
||||
</Section>
|
||||
|
||||
{/* Uploads */}
|
||||
<Section title="Uploads" icon={<ArrowUpFromLine className="h-4 w-4" />} onClick={() => setSection('uploads')}>
|
||||
<Section title="Uploads" icon={<ArrowUpFromLine className="h-4 w-4" />} to={soulseekSectionPath('uploads')}>
|
||||
<Tile label="Uploading" value={uploads.downloading} dot="bg-blue-500" />
|
||||
<Tile label="Queued" value={uploads.queued} dot="bg-amber-500" />
|
||||
<Tile label="Completed" value={uploads.done} dot="bg-green-500" />
|
||||
@@ -105,29 +105,27 @@ export const SoulseekDashboard = () => {
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5">
|
||||
<Search className="h-4 w-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-100">Recent searches</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSection('search')}
|
||||
<Link
|
||||
to={soulseekSectionPath('search')}
|
||||
className="ml-auto text-xs text-zinc-500 transition hover:text-zinc-200"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-zinc-500">No searches yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-white/5">
|
||||
{recent.map((s) => (
|
||||
<button
|
||||
<Link
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setSection('search')}
|
||||
to={`${soulseekSectionPath('search')}?${SEARCH_PARAM}=${s.id}`}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 text-left transition hover:bg-white/5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-zinc-200">{s.searchText}</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">{s.responseCount} responses</span>
|
||||
<span className="shrink-0 text-xs text-zinc-600">{formatWhen(s.startedAt)}</span>
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -137,19 +135,18 @@ export const SoulseekDashboard = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps = { title: string; icon: React.ReactNode; onClick: () => void; children: React.ReactNode };
|
||||
type SectionProps = { title: string; icon: React.ReactNode; to: string; children: React.ReactNode };
|
||||
|
||||
const Section = ({ title, icon, onClick, children }: SectionProps) => (
|
||||
const Section = ({ title, icon, to, children }: SectionProps) => (
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
<Link
|
||||
to={to}
|
||||
className="flex w-full items-center gap-2 border-b border-white/10 px-4 py-2.5 text-left transition hover:bg-white/5"
|
||||
>
|
||||
<span className="text-zinc-400">{icon}</span>
|
||||
<span className="text-sm font-medium text-zinc-100">{title}</span>
|
||||
<RefreshCw className="ml-auto h-3 w-3 text-zinc-600" />
|
||||
</button>
|
||||
</Link>
|
||||
<div className="grid grid-cols-2 gap-2 p-3 sm:grid-cols-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { NavLink } from 'react-router';
|
||||
import {
|
||||
LayoutGrid,
|
||||
Search,
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
Server,
|
||||
Plug,
|
||||
} from 'lucide-react';
|
||||
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
|
||||
import { SOULSEEK_SECTIONS, soulseekSectionPath, type SoulseekSectionId } from './shared';
|
||||
|
||||
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes
|
||||
// the active section to the 'soulseek:section' channel; SoulseekView (right) renders the matching UI.
|
||||
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Entries are
|
||||
// react-router NavLinks to /soulseek/<section>, so active state comes from the router and SoulseekView
|
||||
// (right) learns which section is open by reading the same URL rather than by being told.
|
||||
|
||||
const ICONS: Record<SoulseekSectionId, LucideIcon> = {
|
||||
dashboard: LayoutGrid,
|
||||
@@ -29,8 +30,6 @@ const ICONS: Record<SoulseekSectionId, LucideIcon> = {
|
||||
};
|
||||
|
||||
export const SoulseekNav = () => {
|
||||
const [section, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
@@ -46,26 +45,30 @@ export const SoulseekNav = () => {
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{SOULSEEK_SECTIONS.map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
const active = section === id;
|
||||
return (
|
||||
<button
|
||||
<NavLink
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setSection(id)}
|
||||
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
to={soulseekSectionPath(id)}
|
||||
className={({ isActive }) =>
|
||||
`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react';
|
||||
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
||||
import { useSoulseekUser } from './useSoulseekUser';
|
||||
import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse';
|
||||
import { SharesBrowser, BrowseStateChip } from './SharesBrowser';
|
||||
import { SOULSEEK_USER_CHANNEL, type SoulseekUserRequest } from './shared';
|
||||
import { USER_PARAM } from './shared';
|
||||
|
||||
// Users panel — look up a peer: their presence (online/away/offline) and profile info (description,
|
||||
// upload slots, queue), both cheap live calls (GET /users/{u}/status + /info). Their shared folders come
|
||||
// from Officer's own cache instead, via <SharesBrowser> — see that file for why it isn't browsed live.
|
||||
//
|
||||
// This is also where the workspace's peer actions land: the username dropdown in search results and
|
||||
// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up.
|
||||
// downloads links here with `?user=<name>`, which is the whole mechanism — a peer profile is now
|
||||
// linkable and survives a reload.
|
||||
// Favourites — Officer's own data, since slskd has no such concept — get their own section at the top,
|
||||
// which also serves as this panel's landing content before any lookup.
|
||||
|
||||
@@ -29,9 +30,9 @@ const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof C
|
||||
};
|
||||
|
||||
export const SoulseekUsers = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState('');
|
||||
const [request, setRequest] = usePanelChannel<SoulseekUserRequest | null>(SOULSEEK_USER_CHANNEL, null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const selected = params.get(USER_PARAM)?.trim() ?? '';
|
||||
const [query, setQuery] = useState(selected);
|
||||
const { favorites, isFavorite, toggle } = useSoulseekFavorites();
|
||||
const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots();
|
||||
|
||||
@@ -39,23 +40,22 @@ export const SoulseekUsers = () => {
|
||||
const user = useSoulseekUser(selected);
|
||||
const peer = user.data ?? null;
|
||||
|
||||
// Navigating, not setting state: the peer is `?user=`. Shares are still NOT fetched automatically —
|
||||
// that's a multi-minute server-side job, so it stays an explicit click in the shares card.
|
||||
const select = (name: string) => {
|
||||
const username = name.trim();
|
||||
if (!username) return;
|
||||
setQuery(username);
|
||||
setSelected(username);
|
||||
setParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set(USER_PARAM, username);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and
|
||||
// back doesn't re-run the lookup. Shares are NOT fetched automatically — that's a multi-minute
|
||||
// server-side job, so it stays an explicit click in the shares card.
|
||||
// Arriving by link (or Back) should show the name in the box that produced it.
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
setRequest(null);
|
||||
select(request.username);
|
||||
// select is recreated each render; the effect deliberately runs only when a request arrives.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [request]);
|
||||
if (selected) setQuery(selected);
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user.error) toast.error(user.error.message);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
|
||||
import { SOULSEEK_SECTIONS, useSoulseekSection, type SoulseekSectionId } from './shared';
|
||||
import { SearchView } from './SearchView';
|
||||
import { SoulseekTransfers } from './SoulseekTransfers';
|
||||
import { SoulseekUploads } from './SoulseekUploads';
|
||||
@@ -12,7 +11,7 @@ import { SoulseekSystem } from './SoulseekSystem';
|
||||
import { SoulseekConnection } from './SoulseekConnection';
|
||||
import { useServiceConnection } from '../../hooks/useServiceConnection';
|
||||
|
||||
// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. Content
|
||||
// Right panel of the /soulseek workspace — renders the UI for the section the URL names. Content
|
||||
// zoom used to live here; it is now a framework-level per-panel control in PanelSlot.
|
||||
|
||||
const Placeholder = ({ id }: { id: SoulseekSectionId }) => {
|
||||
@@ -56,7 +55,7 @@ const sectionView = (section: SoulseekSectionId) => {
|
||||
};
|
||||
|
||||
export const SoulseekView = () => {
|
||||
const [section] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
||||
const section = useSoulseekSection();
|
||||
const { data: connection, isLoading } = useServiceConnection('slskd');
|
||||
|
||||
// Nothing connected yet: the setup form takes over every section, because none of them can do anything
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { FolderOpen, Star, StarOff } from 'lucide-react';
|
||||
import {
|
||||
@@ -10,30 +10,19 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
||||
import {
|
||||
SOULSEEK_SECTION_CHANNEL,
|
||||
SOULSEEK_USER_CHANNEL,
|
||||
type SoulseekSectionId,
|
||||
type SoulseekUserRequest,
|
||||
} from './shared';
|
||||
import { soulseekUserPath } from './shared';
|
||||
|
||||
// The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace
|
||||
// and act on that peer. Opening a peer hands the username to the Users section over a panel channel
|
||||
// (which owns the lookup and the cached share tree); favouriting goes to the sidecar's favourites route.
|
||||
// and act on that peer. Opening a peer is a real link to /soulseek/users?user=<name> — the Users section
|
||||
// still owns the lookup and the cached share tree, it just reads who from the URL. Favouriting is a
|
||||
// mutation, so it stays a menu item that acts rather than navigates.
|
||||
|
||||
type UserMenuProps = { username: string };
|
||||
|
||||
export const UserMenu = ({ username }: UserMenuProps) => {
|
||||
const [, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
||||
const [, setRequest] = usePanelChannel<SoulseekUserRequest | null>(SOULSEEK_USER_CHANNEL, null);
|
||||
const { isFavorite, toggle } = useSoulseekFavorites();
|
||||
const favorited = isFavorite(username);
|
||||
|
||||
const openPeer = () => {
|
||||
setRequest({ username, nonce: Date.now() });
|
||||
setSection('users');
|
||||
};
|
||||
|
||||
const toggleFavorite = () => {
|
||||
toggle(username);
|
||||
toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`);
|
||||
@@ -54,9 +43,11 @@ export const UserMenu = ({ username }: UserMenuProps) => {
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel className="truncate">{username}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={openPeer}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
View profile & shares
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={soulseekUserPath(username)}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
View profile & shares
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={toggleFavorite}>
|
||||
{favorited ? <StarOff className="mr-2 h-4 w-4" /> : <Star className="mr-2 h-4 w-4" />}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Shared types/helpers for the /soulseek workspace panels (SoulseekSearch + SoulseekTransfers), which
|
||||
// talk to slskd through the /api/slskd auth proxy and coordinate over a single panel channel.
|
||||
// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/slskd
|
||||
// auth proxy. What is open — the section, the peer, the search — is the URL; the one channel left here
|
||||
// is a refresh signal, which is what channels are for.
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
// Bumped (to a fresh nonce) whenever a download is enqueued from the search panel, so the transfers
|
||||
// panel refetches immediately instead of waiting for its next poll tick.
|
||||
@@ -235,9 +238,8 @@ export type SlskdRoomMessage = {
|
||||
export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] };
|
||||
export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
|
||||
|
||||
// The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads
|
||||
// it and renders the matching UI. Mirrors slskd's own top-menu sections.
|
||||
export const SOULSEEK_SECTION_CHANNEL = 'soulseek:section';
|
||||
// Which section is open is `/soulseek/:section` — the nav panel and the view panel both read the URL and
|
||||
// neither publishes to the other. Mirrors slskd's own top-menu sections.
|
||||
export type SoulseekSectionId =
|
||||
| 'dashboard'
|
||||
| 'search'
|
||||
@@ -260,14 +262,34 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
|
||||
{ id: 'connection', label: 'Connection' },
|
||||
];
|
||||
|
||||
// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users
|
||||
// section. Nonce-stamped and consumed-once (the Users panel clears it) so revisiting the section doesn't
|
||||
// re-run the lookup, while two requests for the SAME username still each trigger a fresh one.
|
||||
export const SOULSEEK_USER_CHANNEL = 'soulseek:user';
|
||||
export type SoulseekUserRequest = { username: string; nonce: number };
|
||||
/** Where /soulseek lands, and where an unrecognised section redirects to. */
|
||||
export const DEFAULT_SOULSEEK_SECTION: SoulseekSectionId = 'dashboard';
|
||||
|
||||
export const isSoulseekSection = (value: string | undefined): value is SoulseekSectionId =>
|
||||
SOULSEEK_SECTIONS.some((s) => s.id === value);
|
||||
|
||||
export const soulseekSectionPath = (section: SoulseekSectionId) => `/soulseek/${section}`;
|
||||
|
||||
// SoulseekScreen redirects anything unrecognised, so the fallback here only covers the instant before
|
||||
// that lands.
|
||||
export const useSoulseekSection = (): SoulseekSectionId => {
|
||||
const { section } = useParams();
|
||||
return isSoulseekSection(section) ? section : DEFAULT_SOULSEEK_SECTION;
|
||||
};
|
||||
|
||||
// Which peer the Users section is showing — `/soulseek/users?user=<name>`.
|
||||
//
|
||||
// This was a `soulseek:user` channel carrying `{username, nonce}`: the username dropdown published a
|
||||
// request, the Users panel consumed it once and cleared it, and the nonce existed so that asking for the
|
||||
// SAME peer twice still counted as two requests. All of that machinery was a workaround for the peer not
|
||||
// being in the URL — a link is idempotent, so there is nothing to consume and no nonce to disambiguate.
|
||||
export const USER_PARAM = 'user';
|
||||
|
||||
export const soulseekUserPath = (username: string) =>
|
||||
`${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`;
|
||||
|
||||
// A past search, as listed by GET /searches (no responses inlined).
|
||||
// Which search's results are open — `/soulseek?search=<id>`, read from the URL rather than held in a
|
||||
// Which search's results are open — `/soulseek/search?search=<id>`, read from the URL rather than held in a
|
||||
// panel channel or local state. See docs/navigation-audit.md.
|
||||
export const SEARCH_PARAM = 'search';
|
||||
|
||||
|
||||
@@ -43,6 +43,15 @@ export type { PhotosSectionId } from './apps/Photos/shared';
|
||||
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
|
||||
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
|
||||
|
||||
// Same for /soulseek, which also puts the open peer in `?user=`.
|
||||
export {
|
||||
DEFAULT_SOULSEEK_SECTION,
|
||||
soulseekSectionPath,
|
||||
soulseekUserPath,
|
||||
isSoulseekSection,
|
||||
} from './apps/Soulseek/shared';
|
||||
export type { SoulseekSectionId } from './apps/Soulseek/shared';
|
||||
|
||||
// Same for /system-monitor, where the param is the scope rather than a section.
|
||||
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
|
||||
export type { MonitorScope } from './apps/SystemMonitor/shared';
|
||||
|
||||
Reference in New Issue
Block a user