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:
@@ -36,7 +36,7 @@ That fix is the template for the HIGH items below. **Caveat:** the fix only did
|
|||||||
## Route map (from `App.tsx`)
|
## Route map (from `App.tsx`)
|
||||||
|
|
||||||
**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/projects/:id`, `/email/:emailId`.
|
**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/projects/:id`, `/email/:emailId`.
|
||||||
**Flat screens:** `/` `/files` `/music` `/soulseek` `/tasks` `/skills` `/processes` `/activity`
|
**Flat screens:** `/` `/files` `/music` `/tasks` `/skills` `/processes` `/activity`
|
||||||
`/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/browser` `/code-editor`.
|
`/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/browser` `/code-editor`.
|
||||||
The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}`
|
The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}`
|
||||||
plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list
|
plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list
|
||||||
@@ -87,12 +87,13 @@ is **one design decision** that cascades across many files:
|
|||||||
- **Music** — `Music/{MusicBrowser:182,209 · MusicDetail:138,174,234 · FavoritesView:85,107}` hold the entire
|
- **Music** — `Music/{MusicBrowser:182,209 · MusicDetail:138,174,234 · FavoritesView:85,107}` hold the entire
|
||||||
library location in `usePanelChannel('music:cwd')`; every drill-in is `setCwd(path)`. No URL, no deep-link to
|
library location in `usePanelChannel('music:cwd')`; every drill-in is `setCwd(path)`. No URL, no deep-link to
|
||||||
an album. → back `music:cwd` with `/music?path=<rel>`; rows become `<Link>`s.
|
an album. → back `music:cwd` with `/music?path=<rel>`; rows become `<Link>`s.
|
||||||
- **Soulseek** — an entire workspace with **no URL at all**: `soulseek:section` nav (`SoulseekNav:40`,
|
- ~~**Soulseek**~~ — **mostly done.** The section is `/soulseek/:section` (nav entries are `NavLink`s, the
|
||||||
`SoulseekDashboard:88/96/110/121`), `soulseek:user` peer channel (`UserMenu:32`, `SoulseekUsers:42`), and
|
view panel reads the same URL), the peer is `?user=<name>`, and the past-search was already `?search=<id>`.
|
||||||
per-panel `useState` for past-search (`SearchView:160`, a textbook `role="button"` div with `s.id` in the
|
The `soulseek:section` and `soulseek:user` channels are deleted; `soulseek:refresh` stays, which is a
|
||||||
closure), rooms (`SoulseekRooms:153`), conversations (`SoulseekChat:135`). → decide a nested tree
|
signal and not selection. The peer went in the query string rather than `/soulseek/users/:name` as
|
||||||
`/soulseek/:section` + `/soulseek/{users/:name, search/:id, rooms/:name, chat/:name}`. **S1 (the section nav)
|
sketched here, because the section nav has to stay a one-segment `NavLink` — a second path segment would
|
||||||
decides S2–S7.**
|
need a nested route just to keep the highlight, and the query string is what `?search=` already uses.
|
||||||
|
Still on `useState`: rooms (`SoulseekRooms`) and conversations (`SoulseekChat`).
|
||||||
|
|
||||||
### 🟡 LOW / borderline
|
### 🟡 LOW / borderline
|
||||||
|
|
||||||
@@ -120,8 +121,6 @@ This is the primary surface to convert to URL-driven selection.
|
|||||||
| `SELECTED_PROJECT` (`useGlobal`) | selected project | `/projects/:id` | `ProjectListApp.tsx:45`, `ProjectPreview.tsx:386` (H3) |
|
| `SELECTED_PROJECT` (`useGlobal`) | selected project | `/projects/:id` | `ProjectListApp.tsx:45`, `ProjectPreview.tsx:386` (H3) |
|
||||||
| `EMAIL_SELECTED` | open email | `/email/:emailId` | `EmailList.tsx:336`, `EmailScreen.tsx` sync (H5) |
|
| `EMAIL_SELECTED` | open email | `/email/:emailId` | `EmailList.tsx:336`, `EmailScreen.tsx` sync (H5) |
|
||||||
| `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` |
|
| `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` |
|
||||||
| `soulseek:section` | active Soulseek section | `/soulseek/:section` | `SoulseekNav/View/Dashboard/UserMenu` |
|
|
||||||
| `soulseek:user` | selected peer | `/soulseek/users/:name` | `UserMenu.tsx:28`, `SoulseekUsers.tsx:34` |
|
|
||||||
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` |
|
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` |
|
||||||
|
|
||||||
**Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`,
|
**Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`,
|
||||||
@@ -201,7 +200,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
|||||||
|
|
||||||
### Phase 3 — Whole-workspace routing decisions (needs a design call first)
|
### Phase 3 — Whole-workspace routing decisions (needs a design call first)
|
||||||
- [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `<Link>` (M-music).
|
- [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `<Link>` (M-music).
|
||||||
- [ ] **Soulseek** — decide the nested tree `/soulseek/:section` + `/soulseek/{users,search,rooms,chat}/:x`; **S1 decides S2–S7** (M-slsk).
|
- [x] **Soulseek** — `/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.**
|
||||||
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
|
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
|
||||||
- [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`).
|
- [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`).
|
||||||
|
|
||||||
|
|||||||
@@ -646,8 +646,16 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
|||||||
key now, so there is nothing left to disagree with.
|
key now, so there is nothing left to disagree with.
|
||||||
**SystemMonitor is done too** (audit M10) — `/system-monitor/:scope`, scope list as `NavLink`s,
|
**SystemMonitor is done too** (audit M10) — `/system-monitor/:scope`, scope list as `NavLink`s,
|
||||||
`monitor:scope` deleted. Smallest of the seven and the same shape as `/photos`.
|
`monitor:scope` deleted. Smallest of the seven and the same shape as `/photos`.
|
||||||
Remaining here: Music, Soulseek, Email, Browser. **Chat detail (`chat:selected-session`) is
|
**Soulseek is done** — `/soulseek/:section`, and the peer moved to `?user=<name>` rather than the
|
||||||
chat-owned — leave it; it is written up in `COMMS/`.**
|
`/soulseek/users/:name` the audit sketched: a second path segment would have needed a nested route
|
||||||
|
just to keep the nav's `NavLink` highlight, and `?search=` was already establishing the convention
|
||||||
|
for that workspace. Deleting `soulseek:user` also deleted the nonce it carried — a request stamped
|
||||||
|
`{username, nonce}`, consumed-once by the Users panel so that returning to the section did not
|
||||||
|
re-run the lookup, and nonce-stamped so that asking for the *same* peer twice still counted twice.
|
||||||
|
A link needs none of that: it is idempotent, so there is nothing to consume and nothing to
|
||||||
|
disambiguate. That is the shape of most of this section's remaining work.
|
||||||
|
Remaining here: Music, Email, Browser. **Chat detail (`chat:selected-session`) is chat-owned —
|
||||||
|
leave it; it is written up in `COMMS/`.**
|
||||||
|
|
||||||
### 5.9 The context has grown an app-config section — _(found 2026-08-07)_
|
### 5.9 The context has grown an app-config section — _(found 2026-08-07)_
|
||||||
|
|
||||||
|
|||||||
@@ -126,10 +126,11 @@ connection status and the header renders it. Panel-scoped by construction, which
|
|||||||
|
|
||||||
### The thirteen channels, honestly
|
### The thirteen channels, honestly
|
||||||
|
|
||||||
13 channels, 26 call sites. Six carry selection that should be in the URL — `music:cwd` (the entire
|
Was 13 channels, 26 call sites. Six carried selection that belongs in the URL, and three of those are
|
||||||
library location, so no album is linkable), `soulseek:section` and `soulseek:user` (the whole Soulseek
|
gone: `soulseek:section` and `soulseek:user` (the whole Soulseek workspace now has one) and
|
||||||
workspace has no URL), `chat:selected-session`, `monitor:scope`. Two are view toggles where a channel is
|
`monitor:scope`. Still to move: `music:cwd` — the entire library location, so no album is linkable — and
|
||||||
genuinely fine (`music:favorites`, `music:lyrics`).
|
`chat:selected-session`, which is the chat's to move, not this document's. Two are view toggles where a
|
||||||
|
channel is genuinely fine (`music:favorites`, `music:lyrics`).
|
||||||
|
|
||||||
Two are wired to nobody: `preview:refresh` and `chat:active-session` have publishers and no subscribers.
|
Two are wired to nobody: `preview:refresh` and `chat:active-session` have publishers and no subscribers.
|
||||||
A third, `file-viewer:<panelId>`, had a subscriber and no publisher — it is gone, along with the
|
A third, `file-viewer:<panelId>`, had a subscriber and no publisher — it is gone, along with the
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function App() {
|
|||||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||||
|
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
|
import { Navigate, useParams } from 'react-router';
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulseekSection } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
// /soulseek uses the Workspace/Panel system (like /chat and /music): a section nav (soulseek-nav) on
|
// /soulseek uses the Workspace/Panel system (like /photos): a section nav (soulseek-nav) on the left and
|
||||||
// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section'
|
// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection —
|
||||||
// channel. Both talk to slskd through the /api/slskd auth proxy.
|
// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy.
|
||||||
|
|
||||||
export const SoulseekScreen = () => {
|
export const SoulseekScreen = () => {
|
||||||
|
const { section } = useParams();
|
||||||
// v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
|
// v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
|
||||||
// layout doesn't resurrect as two mismatched panels.
|
// layout doesn't resurrect as two mismatched panels.
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/soulseek-v2', defaultLayout);
|
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 (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
|
||||||
import { ArrowDownToLine, ArrowUpFromLine, Search, RefreshCw, Radio } from 'lucide-react';
|
import { ArrowDownToLine, ArrowUpFromLine, Search, RefreshCw, Radio } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SOULSEEK_SECTION_CHANNEL,
|
SEARCH_PARAM,
|
||||||
formatWhen,
|
formatWhen,
|
||||||
|
soulseekSectionPath,
|
||||||
transferPhase,
|
transferPhase,
|
||||||
type SlskdApplication,
|
type SlskdApplication,
|
||||||
type SlskdSearchSummary,
|
type SlskdSearchSummary,
|
||||||
type SlskdTransferUser,
|
type SlskdTransferUser,
|
||||||
type SoulseekSectionId,
|
|
||||||
type TransferPhase,
|
type TransferPhase,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
// Dashboard — the at-a-glance overview for the /soulseek workspace: connection health, live
|
// 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
|
// 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;
|
const POLL_MS = 2000;
|
||||||
|
|
||||||
@@ -29,7 +30,6 @@ const tally = (users: SlskdTransferUser[]): Tally => {
|
|||||||
|
|
||||||
export const SoulseekDashboard = () => {
|
export const SoulseekDashboard = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
|
||||||
const [app, setApp] = useState<SlskdApplication | null>(null);
|
const [app, setApp] = useState<SlskdApplication | null>(null);
|
||||||
const [downloads, setDownloads] = useState<Tally>(emptyTally);
|
const [downloads, setDownloads] = useState<Tally>(emptyTally);
|
||||||
const [uploads, setUploads] = useState<Tally>(emptyTally);
|
const [uploads, setUploads] = useState<Tally>(emptyTally);
|
||||||
@@ -85,7 +85,7 @@ export const SoulseekDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Downloads */}
|
{/* 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="Downloading" value={downloads.downloading} dot="bg-blue-500" />
|
||||||
<Tile label="Queued" value={downloads.queued} dot="bg-amber-500" />
|
<Tile label="Queued" value={downloads.queued} dot="bg-amber-500" />
|
||||||
<Tile label="Completed" value={downloads.done} dot="bg-green-500" />
|
<Tile label="Completed" value={downloads.done} dot="bg-green-500" />
|
||||||
@@ -93,7 +93,7 @@ export const SoulseekDashboard = () => {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Uploads */}
|
{/* 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="Uploading" value={uploads.downloading} dot="bg-blue-500" />
|
||||||
<Tile label="Queued" value={uploads.queued} dot="bg-amber-500" />
|
<Tile label="Queued" value={uploads.queued} dot="bg-amber-500" />
|
||||||
<Tile label="Completed" value={uploads.done} dot="bg-green-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">
|
<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" />
|
<Search className="h-4 w-4 text-zinc-400" />
|
||||||
<span className="text-sm font-medium text-zinc-100">Recent searches</span>
|
<span className="text-sm font-medium text-zinc-100">Recent searches</span>
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to={soulseekSectionPath('search')}
|
||||||
onClick={() => setSection('search')}
|
|
||||||
className="ml-auto text-xs text-zinc-500 transition hover:text-zinc-200"
|
className="ml-auto text-xs text-zinc-500 transition hover:text-zinc-200"
|
||||||
>
|
>
|
||||||
View all
|
View all
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{recent.length === 0 ? (
|
{recent.length === 0 ? (
|
||||||
<p className="px-4 py-3 text-sm text-zinc-500">No searches yet.</p>
|
<p className="px-4 py-3 text-sm text-zinc-500">No searches yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-white/5">
|
<div className="divide-y divide-white/5">
|
||||||
{recent.map((s) => (
|
{recent.map((s) => (
|
||||||
<button
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
to={`${soulseekSectionPath('search')}?${SEARCH_PARAM}=${s.id}`}
|
||||||
onClick={() => setSection('search')}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-2 text-left transition hover:bg-white/5"
|
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="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 tabular-nums text-zinc-500">{s.responseCount} responses</span>
|
||||||
<span className="shrink-0 text-xs text-zinc-600">{formatWhen(s.startedAt)}</span>
|
<span className="shrink-0 text-xs text-zinc-600">{formatWhen(s.startedAt)}</span>
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to={to}
|
||||||
onClick={onClick}
|
|
||||||
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"
|
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-zinc-400">{icon}</span>
|
||||||
<span className="text-sm font-medium text-zinc-100">{title}</span>
|
<span className="text-sm font-medium text-zinc-100">{title}</span>
|
||||||
<RefreshCw className="ml-auto h-3 w-3 text-zinc-600" />
|
<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 className="grid grid-cols-2 gap-2 p-3 sm:grid-cols-4">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { NavLink } from 'react-router';
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Search,
|
Search,
|
||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Plug,
|
Plug,
|
||||||
} from 'lucide-react';
|
} 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
|
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Entries are
|
||||||
// the active section to the 'soulseek:section' channel; SoulseekView (right) renders the matching UI.
|
// 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> = {
|
const ICONS: Record<SoulseekSectionId, LucideIcon> = {
|
||||||
dashboard: LayoutGrid,
|
dashboard: LayoutGrid,
|
||||||
@@ -29,8 +30,6 @@ const ICONS: Record<SoulseekSectionId, LucideIcon> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SoulseekNav = () => {
|
export const SoulseekNav = () => {
|
||||||
const [section, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||||
<div className="flex items-center gap-3 px-4 py-4">
|
<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">
|
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||||
{SOULSEEK_SECTIONS.map(({ id, label }) => {
|
{SOULSEEK_SECTIONS.map(({ id, label }) => {
|
||||||
const Icon = ICONS[id];
|
const Icon = ICONS[id];
|
||||||
const active = section === id;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<NavLink
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
to={soulseekSectionPath(id)}
|
||||||
onClick={() => setSection(id)}
|
className={({ isActive }) =>
|
||||||
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||||
active
|
isActive
|
||||||
? 'bg-primary/10 font-medium text-primary'
|
? 'bg-primary/10 font-medium text-primary'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
}`}
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{active && (
|
{({ isActive }) => (
|
||||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
<>
|
||||||
|
{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
|
</NavLink>
|
||||||
className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { useSearchParams } from 'react-router';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react';
|
import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react';
|
||||||
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
||||||
import { useSoulseekUser } from './useSoulseekUser';
|
import { useSoulseekUser } from './useSoulseekUser';
|
||||||
import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse';
|
import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse';
|
||||||
import { SharesBrowser, BrowseStateChip } from './SharesBrowser';
|
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,
|
// 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
|
// 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.
|
// 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
|
// 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,
|
// 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.
|
// 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 = () => {
|
export const SoulseekUsers = () => {
|
||||||
const [query, setQuery] = useState('');
|
const [params, setParams] = useSearchParams();
|
||||||
const [selected, setSelected] = useState('');
|
const selected = params.get(USER_PARAM)?.trim() ?? '';
|
||||||
const [request, setRequest] = usePanelChannel<SoulseekUserRequest | null>(SOULSEEK_USER_CHANNEL, null);
|
const [query, setQuery] = useState(selected);
|
||||||
const { favorites, isFavorite, toggle } = useSoulseekFavorites();
|
const { favorites, isFavorite, toggle } = useSoulseekFavorites();
|
||||||
const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots();
|
const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots();
|
||||||
|
|
||||||
@@ -39,23 +40,22 @@ export const SoulseekUsers = () => {
|
|||||||
const user = useSoulseekUser(selected);
|
const user = useSoulseekUser(selected);
|
||||||
const peer = user.data ?? null;
|
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 select = (name: string) => {
|
||||||
const username = name.trim();
|
const username = name.trim();
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setQuery(username);
|
setParams((prev) => {
|
||||||
setSelected(username);
|
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
|
// Arriving by link (or Back) should show the name in the box that produced it.
|
||||||
// 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.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!request) return;
|
if (selected) setQuery(selected);
|
||||||
setRequest(null);
|
}, [selected]);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.error) toast.error(user.error.message);
|
if (user.error) toast.error(user.error.message);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Construction } from 'lucide-react';
|
import { Construction } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { SOULSEEK_SECTIONS, useSoulseekSection, type SoulseekSectionId } from './shared';
|
||||||
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
|
|
||||||
import { SearchView } from './SearchView';
|
import { SearchView } from './SearchView';
|
||||||
import { SoulseekTransfers } from './SoulseekTransfers';
|
import { SoulseekTransfers } from './SoulseekTransfers';
|
||||||
import { SoulseekUploads } from './SoulseekUploads';
|
import { SoulseekUploads } from './SoulseekUploads';
|
||||||
@@ -12,7 +11,7 @@ import { SoulseekSystem } from './SoulseekSystem';
|
|||||||
import { SoulseekConnection } from './SoulseekConnection';
|
import { SoulseekConnection } from './SoulseekConnection';
|
||||||
import { useServiceConnection } from '../../hooks/useServiceConnection';
|
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.
|
// zoom used to live here; it is now a framework-level per-panel control in PanelSlot.
|
||||||
|
|
||||||
const Placeholder = ({ id }: { id: SoulseekSectionId }) => {
|
const Placeholder = ({ id }: { id: SoulseekSectionId }) => {
|
||||||
@@ -56,7 +55,7 @@ const sectionView = (section: SoulseekSectionId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SoulseekView = () => {
|
export const SoulseekView = () => {
|
||||||
const [section] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
const section = useSoulseekSection();
|
||||||
const { data: connection, isLoading } = useServiceConnection('slskd');
|
const { data: connection, isLoading } = useServiceConnection('slskd');
|
||||||
|
|
||||||
// Nothing connected yet: the setup form takes over every section, because none of them can do anything
|
// 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 { toast } from 'sonner';
|
||||||
import { FolderOpen, Star, StarOff } from 'lucide-react';
|
import { FolderOpen, Star, StarOff } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -10,30 +10,19 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
||||||
import {
|
import { soulseekUserPath } from './shared';
|
||||||
SOULSEEK_SECTION_CHANNEL,
|
|
||||||
SOULSEEK_USER_CHANNEL,
|
|
||||||
type SoulseekSectionId,
|
|
||||||
type SoulseekUserRequest,
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
// The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace
|
// 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
|
// and act on that peer. Opening a peer is a real link to /soulseek/users?user=<name> — the Users section
|
||||||
// (which owns the lookup and the cached share tree); favouriting goes to the sidecar's favourites route.
|
// 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 };
|
type UserMenuProps = { username: string };
|
||||||
|
|
||||||
export const UserMenu = ({ username }: UserMenuProps) => {
|
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 { isFavorite, toggle } = useSoulseekFavorites();
|
||||||
const favorited = isFavorite(username);
|
const favorited = isFavorite(username);
|
||||||
|
|
||||||
const openPeer = () => {
|
|
||||||
setRequest({ username, nonce: Date.now() });
|
|
||||||
setSection('users');
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleFavorite = () => {
|
const toggleFavorite = () => {
|
||||||
toggle(username);
|
toggle(username);
|
||||||
toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`);
|
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">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
<DropdownMenuLabel className="truncate">{username}</DropdownMenuLabel>
|
<DropdownMenuLabel className="truncate">{username}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={openPeer}>
|
<DropdownMenuItem asChild>
|
||||||
<FolderOpen className="mr-2 h-4 w-4" />
|
<Link to={soulseekUserPath(username)}>
|
||||||
View profile & shares
|
<FolderOpen className="mr-2 h-4 w-4" />
|
||||||
|
View profile & shares
|
||||||
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={toggleFavorite}>
|
<DropdownMenuItem onSelect={toggleFavorite}>
|
||||||
{favorited ? <StarOff className="mr-2 h-4 w-4" /> : <Star className="mr-2 h-4 w-4" />}
|
{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
|
// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/slskd
|
||||||
// talk to slskd through the /api/slskd auth proxy and coordinate over a single panel channel.
|
// 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
|
// 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.
|
// 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 SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] };
|
||||||
export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
|
export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
|
||||||
|
|
||||||
// The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads
|
// Which section is open is `/soulseek/:section` — the nav panel and the view panel both read the URL and
|
||||||
// it and renders the matching UI. Mirrors slskd's own top-menu sections.
|
// neither publishes to the other. Mirrors slskd's own top-menu sections.
|
||||||
export const SOULSEEK_SECTION_CHANNEL = 'soulseek:section';
|
|
||||||
export type SoulseekSectionId =
|
export type SoulseekSectionId =
|
||||||
| 'dashboard'
|
| 'dashboard'
|
||||||
| 'search'
|
| 'search'
|
||||||
@@ -260,14 +262,34 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
|
|||||||
{ id: 'connection', label: 'Connection' },
|
{ id: 'connection', label: 'Connection' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users
|
/** Where /soulseek lands, and where an unrecognised section redirects to. */
|
||||||
// section. Nonce-stamped and consumed-once (the Users panel clears it) so revisiting the section doesn't
|
export const DEFAULT_SOULSEEK_SECTION: SoulseekSectionId = 'dashboard';
|
||||||
// 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 const isSoulseekSection = (value: string | undefined): value is SoulseekSectionId =>
|
||||||
export type SoulseekUserRequest = { username: string; nonce: number };
|
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).
|
// 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.
|
// panel channel or local state. See docs/navigation-audit.md.
|
||||||
export const SEARCH_PARAM = 'search';
|
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 { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
|
||||||
export type { JellyfinSectionId } 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.
|
// 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 { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
|
||||||
export type { MonitorScope } from './apps/SystemMonitor/shared';
|
export type { MonitorScope } from './apps/SystemMonitor/shared';
|
||||||
|
|||||||
Reference in New Issue
Block a user