feat(website): add the project site and deploy it to GitHub Pages
A Vite + React + TypeScript + Tailwind v4 site under website/, built from the soulseek-rs design system so the site and the TUI stay one product. The colour tokens in src/index.css are the same values as soulseek-rs/src/ui/styles.rs; change one, change both. The hero is a working recreation of the TUI: it plays one scripted run and then takes the keyboard, or lands on the resting state directly under prefers-reduced-motion. Its sample data is invented or public domain, per the tone-of-voice guardrail against naming copyrighted material. Static output rather than an SPA shell. `bun run build` compiles the client, compiles the same app for Node, then renders every route in src/lib/routes.ts to its own file with that route's title, description and canonical URL. A direct link to /install therefore answers 200 with real markup instead of GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding one without the other fails typecheck rather than shipping a page whose body says "not found" under the right title. Theme is resolved once by an inline script before first paint and adopted by React through useSyncExternalStore, so there is no flash and no hydration mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor and tape read on vinyl but wash out on manila. CI and release skip website-only pushes, and Pages skips Rust-only ones. The two Rust workflows share one paths-ignore list so they can only skip together, which keeps the release ci-gate from polling for a run that was never queued. That gate also now treats a cancelled CI as "keep waiting" rather than a failure, since cancel-in-progress cancels the older SHA on every back-to-back push.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FeatureCard } from '@/components/ui/feature-card'
|
||||
import { Code } from '@/components/ui/inline-code'
|
||||
import { Cols, Eyebrow, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout'
|
||||
import { Callout } from '@/components/ui/panel'
|
||||
import { Terminal } from '@/components/ui/terminal'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
interface Alternative {
|
||||
question: string
|
||||
answer: React.ReactNode
|
||||
links: readonly { label: string; href: string }[]
|
||||
}
|
||||
|
||||
const ALTERNATIVES: readonly Alternative[] = [
|
||||
{
|
||||
question: 'Want a window and a mouse?',
|
||||
answer: <>SoulseekQt or Nicotine+. Both are good.</>,
|
||||
links: [
|
||||
{ label: 'SoulseekQt', href: LINKS.slsknet },
|
||||
{ label: 'Nicotine+', href: LINKS.nicotine },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Want feature completeness?',
|
||||
answer: (
|
||||
<>
|
||||
Nicotine+ has more: wishlists, user lists, transfer rules, plugins, translations.
|
||||
It’s mature and well run.
|
||||
</>
|
||||
),
|
||||
links: [{ label: 'Nicotine+', href: LINKS.nicotine }],
|
||||
},
|
||||
{
|
||||
question: 'Want to point automation at the network?',
|
||||
answer: <>slskd does that today. Daemon mode here is unbuilt.</>,
|
||||
links: [{ label: 'slskd', href: LINKS.slskd }],
|
||||
},
|
||||
]
|
||||
|
||||
const CITIZENSHIP = [
|
||||
{
|
||||
glyph: '↑',
|
||||
color: 'var(--status-success)',
|
||||
title: 'Share, and share real files',
|
||||
body: (
|
||||
<>
|
||||
Point <code>--shared-dir</code> at the collection you actually listen to. Full
|
||||
albums, honest bitrates, nothing transcoded up from a lossy source and relabelled.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '≡',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Organize so others can find it',
|
||||
body: (
|
||||
<>
|
||||
Artist and album folders, tagged files, readable names. Someone else’s search
|
||||
only reaches you if your filenames say what the music is.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '∞',
|
||||
color: 'var(--accent)',
|
||||
title: 'Stay online',
|
||||
body: (
|
||||
<>
|
||||
Leave it running in a tmux session on the box that is already on. A share that
|
||||
appears for an hour a week is close to no share at all.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '↔',
|
||||
color: 'var(--status-warning)',
|
||||
title: 'Let people reach you',
|
||||
body: (
|
||||
<>
|
||||
Forward your listen port or let <code>portmap</code> try UPnP, and leave a free
|
||||
upload slot. Two firewalled peers cannot trade with each other.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '@',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Answer people',
|
||||
body: (
|
||||
<>
|
||||
Rooms and private messages are part of the client for a reason. Someone asking
|
||||
where the rest of a discography went is worth a reply.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '⋯',
|
||||
color: 'var(--text-secondary)',
|
||||
title: 'Wait your turn',
|
||||
body: (
|
||||
<>
|
||||
Queues are one person’s upload bandwidth being divided up. Queue it, leave it,
|
||||
come back later. Do not requeue the same file to jump the line.
|
||||
</>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export const Community = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
eyebrow="community"
|
||||
title="Help out, or go where you're better served."
|
||||
>
|
||||
It’s mostly one person who’s been on Soulseek since the early 2000s.
|
||||
Issues and pull requests are welcome, and if another client fits you better, use it.
|
||||
</PageHead>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="contributing" title="How to help.">
|
||||
File an issue, open a pull request, or just say what’s missing. No
|
||||
contributor agreement, no template gymnastics.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Prose>
|
||||
<p>
|
||||
The project is a Cargo workspace: <Code>soulseek-rs-lib</Code> (the protocol)
|
||||
and <Code>soulseek-rs</Code> (the client). The library stays lean on
|
||||
dependencies; the client takes them freely.
|
||||
</p>
|
||||
<p>
|
||||
Format and Clippy run clean on every push, and unit tests plus an end-to-end
|
||||
suite against{' '}
|
||||
<a
|
||||
href={LINKS.soulfind}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
soulfind
|
||||
</a>{' '}
|
||||
run in CI, so a green checkmark means it genuinely built and talked to a
|
||||
server.
|
||||
</p>
|
||||
<p>
|
||||
That server is{' '}
|
||||
<a
|
||||
href={LINKS.soulfind}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
soulfind-dev/soulfind
|
||||
</a>
|
||||
, an open-source Soulseek server. Run it locally and you can develop and test
|
||||
against a real protocol implementation without touching the public network.
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href={LINKS.issues} variant="accent">
|
||||
Open an issue ↗
|
||||
</Button>
|
||||
<Button href={LINKS.gh}>GitHub ↗</Button>
|
||||
</div>
|
||||
</Prose>
|
||||
<Terminal
|
||||
label="develop"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'RUST_LOG=trace cargo run' },
|
||||
{ t: 'cm', text: '# run with debug + trace output' },
|
||||
{ t: 'cmd', text: 'cargo test' },
|
||||
{ t: 'cmd', text: 'cargo clippy' },
|
||||
{ t: 'cmd', text: 'cargo fmt' },
|
||||
]}
|
||||
/>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead
|
||||
eyebrow="being a good member"
|
||||
title="The network is other people's hard drives."
|
||||
>
|
||||
Soulseek is a gem for music enthusiasts. Decades of records, live sets, and
|
||||
pressings that never made it anywhere else, kept online by people who care about
|
||||
them. It works because someone left their collection up for you. None of this is
|
||||
enforced. It is just how the network stays worth using.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{CITIZENSHIP.map((item) => (
|
||||
<FeatureCard
|
||||
key={item.title}
|
||||
glyph={item.glyph}
|
||||
color={item.color}
|
||||
title={item.title}
|
||||
>
|
||||
{item.body}
|
||||
</FeatureCard>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead
|
||||
eyebrow="what this is not"
|
||||
title="Three good clients to point you elsewhere."
|
||||
>
|
||||
soulseek-rs has no GUI and no daemon mode yet. If one of these fits you better,
|
||||
use it. Recommending them is the strongest trust signal there is.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-3.5">
|
||||
{ALTERNATIVES.map((alt) => (
|
||||
<div
|
||||
key={alt.question}
|
||||
className="grid grid-cols-1 items-center gap-4 rounded-md border border-hairline bg-panel p-[18px] sm:grid-cols-[1fr_auto] sm:gap-x-6 sm:p-[22px] lg:grid-cols-1"
|
||||
>
|
||||
<div>
|
||||
<div className="mb-1.5 text-[15px] font-medium text-primary">
|
||||
{alt.question}
|
||||
</div>
|
||||
<div className="text-[13.5px] leading-[22px] text-secondary">
|
||||
{alt.answer}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||
{alt.links.map((link) => (
|
||||
<Button key={link.label} href={link.href}>
|
||||
{link.label} ↗
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<Cols center className="gap-8">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<Eyebrow>license & promises</Eyebrow>
|
||||
<h2 className="text-[26px] leading-[34px] font-medium sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
MIT, and no strings.
|
||||
</h2>
|
||||
<p className="text-secondary">
|
||||
Free to use, copy, modify, and distribute. Copyright © 2026 Michel de Graaf.
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href={LINKS.license} variant="accent">
|
||||
Read the license ↗
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Callout title="two things that stay true">
|
||||
<p className="mb-1.5 !text-success">No telemetry. Ever.</p>
|
||||
<p className="font-forum text-xs !text-muted">
|
||||
Not affiliated with or endorsed by the Soulseek project.
|
||||
</p>
|
||||
</Callout>
|
||||
</Cols>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Code } from '@/components/ui/inline-code'
|
||||
import {
|
||||
Cols,
|
||||
Eyebrow,
|
||||
PageHead,
|
||||
Prose,
|
||||
Section,
|
||||
SectionHead,
|
||||
} from '@/components/ui/layout'
|
||||
import { Callout, Panel } from '@/components/ui/panel'
|
||||
import { Terminal } from '@/components/ui/terminal'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
|
||||
['Space', 'select a result'],
|
||||
['Enter', 'download / send'],
|
||||
['b', "browse the owner's files"],
|
||||
['c', 'chat rooms'],
|
||||
['m', 'compose a private message'],
|
||||
['i', 'inbox (unread counter)'],
|
||||
['/', 'filter the current list'],
|
||||
['a / A', 'select all / none'],
|
||||
['1–3', 'focus a pane'],
|
||||
['q', 'quit'],
|
||||
]
|
||||
|
||||
const ROOM_KEYS: readonly (readonly [string, string])[] = [
|
||||
['Enter', 'join room / send message'],
|
||||
['Tab · ⇧Tab', 'switch between open rooms'],
|
||||
['x', 'leave the active room'],
|
||||
['l', 'back to the room list'],
|
||||
['↑ ↓', 'select a member'],
|
||||
['b', "browse the member's files"],
|
||||
['m', 'message the member'],
|
||||
]
|
||||
|
||||
const PANES: readonly { num?: number; title: string; body: string }[] = [
|
||||
{
|
||||
num: 1,
|
||||
title: 'Searches',
|
||||
body: 'Your queries and how many results each returned. Type / to search the network; each search keeps its own result set.',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
title: 'Results',
|
||||
body: 'Files from the network: size, user, bitrate, speed, free slots. Space selects, Enter queues a download.',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
title: 'Downloads / Uploads',
|
||||
body: 'Live transfers in both directions with progress and speed: queued, active, complete, failed.',
|
||||
},
|
||||
{
|
||||
title: 'Info',
|
||||
body: 'Everything about the selected file: user, size, path, bitrate, length, queue position, free slots.',
|
||||
},
|
||||
]
|
||||
|
||||
const KeyRow = ({ combo, description }: { combo: string; description: string }) => (
|
||||
<div className="grid grid-cols-[120px_1fr] items-baseline gap-3 border-b border-hairline py-[7px]">
|
||||
<span>
|
||||
<span className="inline-block min-w-5 rounded-md border border-line border-b-2 px-[7px] text-center text-xs text-primary">
|
||||
{combo}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[13px] text-secondary">{description}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Docs = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
eyebrow="docs · quick start"
|
||||
title="Install it, run it, drive it from the keyboard."
|
||||
>
|
||||
This is the short version. The full reference (every flag, every message) lives in
|
||||
the README and on docs.rs.
|
||||
</PageHead>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="1 · first run" title="Two commands to a running client.">
|
||||
You need a Rust toolchain. After <Code>cargo install</Code>, the binary is on your
|
||||
PATH.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Terminal
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo install soulseek-rs' },
|
||||
{ t: 'cmd', text: 'soulseek-rs' },
|
||||
{ t: 'cm', text: '# the TUI opens; log in or register on first run' },
|
||||
]}
|
||||
/>
|
||||
<Prose>
|
||||
<p>
|
||||
On first run soulseek-rs shows a login screen: sign in with an existing
|
||||
Soulseek account or register a new one, right in the TUI. Your password is
|
||||
stored in the OS keychain, not in plain text.
|
||||
</p>
|
||||
<p>
|
||||
Downloads land in a conventional folder by default, and your TUI state
|
||||
(searches, downloads, rooms) is saved and restored across restarts.
|
||||
</p>
|
||||
</Prose>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="2 · the layout" title="Four panes.">
|
||||
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
|
||||
one; the focused pane’s legend turns green.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{PANES.map((pane) => (
|
||||
<Panel key={pane.title} num={pane.num} title={pane.title}>
|
||||
<p className="text-[13px] leading-[22px] text-secondary">{pane.body}</p>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="3 · keys" title="It's all keyboard.">
|
||||
The bottom bar always shows the keys for where you are. The essentials:
|
||||
</SectionHead>
|
||||
<Cols>
|
||||
<div>
|
||||
<Eyebrow className="mb-4 block">anywhere</Eyebrow>
|
||||
<div className="flex flex-col">
|
||||
{GLOBAL_KEYS.map(([combo, description]) => (
|
||||
<KeyRow key={combo} combo={combo} description={description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow className="mb-4 block">
|
||||
in the chat-rooms popup <span className="text-muted">(press c)</span>
|
||||
</Eyebrow>
|
||||
<div className="flex flex-col">
|
||||
{ROOM_KEYS.map(([combo, description]) => (
|
||||
<KeyRow key={combo} combo={combo} description={description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead
|
||||
eyebrow="4 · from the shell"
|
||||
title="Every command also runs as a one-off."
|
||||
>
|
||||
Skip the TUI when you’re scripting or mid-task.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Terminal
|
||||
label="shell"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'soulseek-rs "public domain field recordings"' },
|
||||
{ t: 'cm', text: '# open the TUI straight onto a search' },
|
||||
{ t: 'cmd', text: 'soulseek-rs message <username> "hello there"' },
|
||||
{ t: 'cmd', text: 'soulseek-rs rooms' },
|
||||
{ t: 'cm', text: '# list public rooms, busiest first' },
|
||||
{ t: 'cmd', text: 'soulseek-rs chat <room> "hello room"' },
|
||||
{ t: 'cmd', text: 'soulseek-rs portmap' },
|
||||
]}
|
||||
/>
|
||||
<Prose>
|
||||
<p>
|
||||
The subcommands mirror the TUI: <Code>search</Code>, <Code>message</Code>,{' '}
|
||||
<Code>browse</Code>, <Code>rooms</Code>, <Code>chat</Code>, and{' '}
|
||||
<Code>portmap</Code>. Pass a room and a message to <Code>chat</Code> to say
|
||||
one thing and exit.
|
||||
</p>
|
||||
<p>
|
||||
<Code>portmap</Code> tests whether your router will let peers connect back,
|
||||
worth running once.{' '}
|
||||
<Link to="/install" className="text-link hover:text-link-hover">
|
||||
More on being reachable →
|
||||
</Link>
|
||||
</p>
|
||||
</Prose>
|
||||
</Cols>
|
||||
<div className="mt-6">
|
||||
<Callout tone="warn" title="the one limit worth knowing up front">
|
||||
<p>
|
||||
If both you and a peer are behind routers with no forwarded port, browsing
|
||||
that peer can’t work, that’s a fundamental Soulseek/peer-to-peer
|
||||
limitation, not a bug.
|
||||
</p>
|
||||
</Callout>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<Panel title="the rest">
|
||||
<Cols center className="gap-6">
|
||||
<Prose>
|
||||
<p>
|
||||
Deeper docs live where they stay current: the full README on GitHub, and the
|
||||
generated API reference for the library on docs.rs.
|
||||
</p>
|
||||
</Prose>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href={LINKS.gh} variant="accent">
|
||||
README ↗
|
||||
</Button>
|
||||
<Button href={LINKS.docsrs}>docs.rs ↗</Button>
|
||||
<Button href={LINKS.changelog}>Changelog ↗</Button>
|
||||
</div>
|
||||
</Cols>
|
||||
</Panel>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { Logo } from '@/components/brand/logo'
|
||||
import { HeroTerminal } from '@/components/tui/hero-terminal'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CommandPill } from '@/components/ui/command-pill'
|
||||
import { FeatureCard } from '@/components/ui/feature-card'
|
||||
import { Code } from '@/components/ui/inline-code'
|
||||
import { Cols, Eyebrow, Prose, Section, SectionHead, Wrap } from '@/components/ui/layout'
|
||||
import { OsIcon, type OsName } from '@/components/ui/os-icon'
|
||||
import { Callout } from '@/components/ui/panel'
|
||||
import { Terminal } from '@/components/ui/terminal'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
const PLATFORMS: readonly { name: OsName; note: string; anchor: string }[] = [
|
||||
{ name: 'macOS', note: 'Apple Keychain', anchor: 'macos' },
|
||||
{ name: 'Linux', note: 'Secret Service', anchor: 'linux' },
|
||||
{ name: 'Windows', note: 'Credential Manager', anchor: 'windows' },
|
||||
]
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
glyph: '→',
|
||||
color: 'var(--accent)',
|
||||
title: 'Search & download',
|
||||
cmd: 'soulseek-rs "field recordings"',
|
||||
body: <>Search the network, pick results in the TUI, and queue downloads with pause, resume, and retry.</>,
|
||||
},
|
||||
{
|
||||
glyph: '↑',
|
||||
color: 'var(--status-success)',
|
||||
title: 'Sharing',
|
||||
cmd: 'soulseek-rs --shared-dir ~/Music',
|
||||
body: (
|
||||
<>
|
||||
Point <code>--shared-dir</code> at a directory and your files show up in searches;
|
||||
peers browse and download them.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '›',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Browse',
|
||||
cmd: 'soulseek-rs browse <user>',
|
||||
body: <>List any user’s shared files and download straight from the tree.</>,
|
||||
},
|
||||
{
|
||||
glyph: '#',
|
||||
color: 'var(--status-warning)',
|
||||
title: 'Chat rooms',
|
||||
cmd: 'soulseek-rs rooms',
|
||||
body: <>List, join, and talk in public rooms, several open at once as tabs.</>,
|
||||
},
|
||||
{
|
||||
glyph: '@',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Private messages',
|
||||
cmd: 'soulseek-rs message <user> "hi"',
|
||||
body: <>Send and receive PMs, with an inbox and an unread counter in the TUI.</>,
|
||||
},
|
||||
{
|
||||
glyph: '↔',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Firewalled peers',
|
||||
cmd: 'soulseek-rs --listener-port 2234',
|
||||
body: (
|
||||
<>
|
||||
Downloads and browsing fall back to a server-brokered connection when a peer
|
||||
can’t be reached directly.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '↯',
|
||||
color: 'var(--status-warning)',
|
||||
title: 'Automatic port mapping',
|
||||
cmd: 'soulseek-rs portmap',
|
||||
body: (
|
||||
<>
|
||||
Opens your listen port via UPnP-IGD and NAT-PMP, with a <code>portmap</code>{' '}
|
||||
subcommand to test your router.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '▮',
|
||||
color: 'var(--accent)',
|
||||
title: 'TUI and CLI',
|
||||
cmd: 'soulseek-rs',
|
||||
body: (
|
||||
<>
|
||||
A full terminal interface, plus scriptable subcommands: search, message, browse,
|
||||
rooms, chat, portmap.
|
||||
</>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export const Home = () => {
|
||||
return (
|
||||
<>
|
||||
<section className="pt-5 pb-8 sm:pt-7 sm:pb-10">
|
||||
<Wrap>
|
||||
<div className="flex flex-col gap-[22px]">
|
||||
<Eyebrow>Soulseek client · terminal · Rust</Eyebrow>
|
||||
<div className="flex items-start gap-3.5 sm:gap-6 md:gap-11">
|
||||
<Logo size={172} className="mt-2 shrink-0 max-md:!h-28 max-sm:!h-16" />
|
||||
<h1 className="text-[29px] leading-[37px] font-bold tracking-[-0.01em] text-balance sm:text-[34px] sm:leading-[42px] md:text-display md:leading-[var(--text-display--line-height)]">
|
||||
A Soulseek client for people who live in the terminal.
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-lg leading-[29px] text-pretty text-secondary">
|
||||
Search the network, share your files, browse someone’s collection, join
|
||||
a room. It runs over ssh on the machine where your music already lives, with
|
||||
no window and no mouse.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-3 sm:gap-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 max-sm:w-full sm:gap-3">
|
||||
<Button href="/install" variant="accent">
|
||||
Install
|
||||
</Button>
|
||||
<Button href="/docs">Read the docs</Button>
|
||||
<Button href={LINKS.gh}>GitHub ↗</Button>
|
||||
</div>
|
||||
<CommandPill text="cargo install soulseek-rs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 overflow-hidden">
|
||||
<HeroTerminal />
|
||||
</div>
|
||||
</Wrap>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-hairline py-6">
|
||||
<Wrap>
|
||||
<div className="flex flex-wrap items-center gap-3.5 sm:gap-8">
|
||||
<Eyebrow>Runs on</Eyebrow>
|
||||
<ul className="flex w-full flex-wrap sm:w-auto">
|
||||
{PLATFORMS.map((platform, i) => (
|
||||
<li
|
||||
key={platform.name}
|
||||
className={
|
||||
i === 0
|
||||
? 'flex w-full sm:w-auto sm:pr-6.5'
|
||||
: 'flex w-full border-hairline sm:w-auto sm:border-l sm:px-6.5'
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to={`/install#${platform.anchor}`}
|
||||
className="group flex w-full items-center gap-2.5 border-t border-hairline py-2.5 first:border-t-0 sm:w-auto sm:border-t-0 sm:py-0"
|
||||
>
|
||||
<OsIcon name={platform.name} />
|
||||
<span className="flex flex-col leading-[1.35]">
|
||||
<b className="text-[13.5px] font-medium text-primary transition-colors group-hover:text-accent">
|
||||
{platform.name}
|
||||
</b>
|
||||
<span className="text-[11.5px] text-secondary">{platform.note}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Wrap>
|
||||
</section>
|
||||
|
||||
<Section id="about">
|
||||
<SectionHead
|
||||
eyebrow="what it is"
|
||||
title="A client for a network that runs on people."
|
||||
>
|
||||
No charts, no recommendations. You search, you find the person who has it, you
|
||||
download it from them.
|
||||
</SectionHead>
|
||||
<Cols>
|
||||
<Prose>
|
||||
<p>
|
||||
Soulseek is a peer-to-peer network from the early 2000s. People still use it
|
||||
to share niche music by hand: the album that exists in one person’s
|
||||
folder and nowhere else.
|
||||
</p>
|
||||
<p>
|
||||
soulseek-rs speaks that protocol. It’s a third-party client, not the
|
||||
official app, and not affiliated with the Soulseek project. It shares,
|
||||
browses, and does rooms and private messages, because a client that only
|
||||
downloads takes from the network without giving anything back.
|
||||
</p>
|
||||
</Prose>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Callout title="build your own">
|
||||
<p>
|
||||
Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent
|
||||
on the protocol directly.{' '}
|
||||
<Link to="/install" className="text-link hover:text-link-hover">
|
||||
Use the library →
|
||||
</Link>
|
||||
</p>
|
||||
</Callout>
|
||||
<Callout title="share back">
|
||||
<p>
|
||||
Point <Code>--shared-dir</Code> at your library and leave it running in a
|
||||
tmux session on your share or headless box. The network only works because
|
||||
people share, and users who share back tend to get queue slots faster.
|
||||
</p>
|
||||
</Callout>
|
||||
</div>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section id="features">
|
||||
<SectionHead eyebrow="features" title="What it does.">
|
||||
Everything happens in the TUI, and every command also runs as a one-off from the
|
||||
shell, so the same client works for scripting, cron jobs, and agents driving it
|
||||
programmatically.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map((feature) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
glyph={feature.glyph}
|
||||
color={feature.color}
|
||||
title={feature.title}
|
||||
cmd={feature.cmd}
|
||||
>
|
||||
{feature.body}
|
||||
</FeatureCard>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<Cols center>
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<Eyebrow>get started</Eyebrow>
|
||||
<h2 className="text-[26px] leading-[34px] font-medium text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
Install it, then press <span className="text-accent">/</span> to search.
|
||||
</h2>
|
||||
<p className="text-secondary">
|
||||
Needs a Rust toolchain. From there it’s one command, then the TUI opens.
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href="/docs" variant="accent">
|
||||
Read the docs
|
||||
</Button>
|
||||
<Button href="/install">Install options</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Terminal
|
||||
label="quick start"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo install soulseek-rs' },
|
||||
{ t: 'cmd', text: 'soulseek-rs' },
|
||||
]}
|
||||
/>
|
||||
</Cols>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FeatureCard } from '@/components/ui/feature-card'
|
||||
import { Code } from '@/components/ui/inline-code'
|
||||
import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout'
|
||||
import { Callout } from '@/components/ui/panel'
|
||||
import { Terminal, type TermLine } from '@/components/ui/terminal'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
const LIB_SRC = `use soulseek_rs::Client;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut client = Client::new("username", "password");
|
||||
client.connect();
|
||||
client.login()?;
|
||||
|
||||
let results = client.search("public domain field recordings", Duration::from_secs(10))?;
|
||||
if let Some(r) = results.iter().find(|r| !r.files.is_empty()) {
|
||||
let f = &r.files[0];
|
||||
client.download(f.name.clone(), f.username.clone(), f.size, "~/Downloads".to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}`
|
||||
|
||||
// LIB_SRC carries no comment lines, so every line is plain code.
|
||||
const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({
|
||||
t: 'code' as const,
|
||||
text,
|
||||
}))
|
||||
|
||||
interface PlatformNote {
|
||||
id: string
|
||||
name: string
|
||||
credentialStore: string
|
||||
config: string
|
||||
state: string
|
||||
downloads: string
|
||||
note: string
|
||||
}
|
||||
|
||||
const PLATFORMS: readonly PlatformNote[] = [
|
||||
{
|
||||
id: 'macos',
|
||||
name: 'macOS',
|
||||
credentialStore: 'Apple Keychain',
|
||||
config: '~/.config/soulseek-rs/config.toml',
|
||||
state: '~/.local/share/soulseek-rs/state',
|
||||
downloads: '~/Downloads/Soulseek',
|
||||
note: 'macOS deliberately follows the XDG layout rather than Application Support, so config sits beside your other dotfile config.',
|
||||
},
|
||||
{
|
||||
id: 'linux',
|
||||
name: 'Linux',
|
||||
credentialStore: 'Secret Service (pure-Rust backend, no libdbus needed)',
|
||||
config: '~/.config/soulseek-rs/config.toml',
|
||||
state: '~/.local/share/soulseek-rs/state',
|
||||
downloads: '~/Downloads/Soulseek',
|
||||
note: 'Honours XDG_CONFIG_HOME and XDG_DATA_HOME when they are set.',
|
||||
},
|
||||
{
|
||||
id: 'windows',
|
||||
name: 'Windows',
|
||||
credentialStore: 'Windows Credential Manager',
|
||||
config: '%APPDATA%\\soulseek-rs\\config\\config.toml',
|
||||
state: '%APPDATA%\\soulseek-rs\\data\\state',
|
||||
downloads: '%USERPROFILE%\\Downloads\\Soulseek',
|
||||
note: 'Uses the standard known-folder locations for roaming app data.',
|
||||
},
|
||||
]
|
||||
|
||||
interface StepProps {
|
||||
n: number
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Step = ({ n, title, children }: StepProps) => (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[auto_1fr] sm:gap-5">
|
||||
<div className="flex size-[34px] items-center justify-center rounded-md border border-line text-sm text-accent">
|
||||
{n}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<h2 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Install = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHead eyebrow="install" title="Three ways in.">
|
||||
One command if you have Rust. Build from source if you’d rather. Or depend on
|
||||
the library and write your own client.
|
||||
</PageHead>
|
||||
|
||||
<Section>
|
||||
<div className="flex flex-col gap-9">
|
||||
<Step n={1} title="Install the client">
|
||||
<p className="text-secondary">
|
||||
Needs a Rust toolchain, available from{' '}
|
||||
<a
|
||||
href={LINKS.rustup}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
rustup.rs
|
||||
</a>
|
||||
. Then:
|
||||
</p>
|
||||
<Terminal
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo install soulseek-rs' },
|
||||
{ t: 'cmd', text: 'soulseek-rs' },
|
||||
]}
|
||||
/>
|
||||
<p className="text-[13px] text-muted">
|
||||
Cargo puts the binary on your PATH. Run <Code>soulseek-rs</Code> and the TUI
|
||||
opens.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href={LINKS.releases}>Prebuilt binaries ↗</Button>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted">
|
||||
No Rust toolchain? Grab a prebuilt archive for your platform from the{' '}
|
||||
<a
|
||||
href={LINKS.releases}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
releases page
|
||||
</a>
|
||||
, unpack it, and put the binary somewhere on your PATH.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Build from source">
|
||||
<p className="text-secondary">
|
||||
Clone the workspace and build a release binary.
|
||||
</p>
|
||||
<Terminal
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'git clone https://github.com/michel/soulseek-rs.git' },
|
||||
{ t: 'cmd', text: 'cd soulseek-rs' },
|
||||
{ t: 'cmd', text: 'cargo build --release' },
|
||||
]}
|
||||
/>
|
||||
<p className="text-[13px] text-muted">
|
||||
You’ll find the binary at <Code>target/release/soulseek-rs</Code>.
|
||||
Prebuilt archives for tagged releases are on the{' '}
|
||||
<a
|
||||
href={LINKS.releases}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
releases page
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Build on the library">
|
||||
<p className="text-secondary">
|
||||
Write your own client or bot on <Code>soulseek-rs-lib</Code>: the protocol
|
||||
implementation, separate from the TUI.
|
||||
</p>
|
||||
<Terminal
|
||||
label="Cargo.toml"
|
||||
lines={[
|
||||
{ t: 'cm', text: '[dependencies]' },
|
||||
{ t: 'code', text: 'soulseek-rs-lib = "6.0.0"' },
|
||||
]}
|
||||
copy={'[dependencies]\nsoulseek-rs-lib = "6.0.0"'}
|
||||
/>
|
||||
<Terminal label="src/main.rs" lines={LIB_LINES} copy={LIB_SRC} />
|
||||
<p className="text-[13px] text-muted">
|
||||
Full API reference on{' '}
|
||||
<a
|
||||
href={LINKS.docsrs}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
docs.rs
|
||||
</a>
|
||||
. Note: v6 moved sharing from one directory to a list. See the{' '}
|
||||
<a
|
||||
href={LINKS.changelog}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
changelog
|
||||
</a>{' '}
|
||||
if you’re upgrading from 5.x.
|
||||
</p>
|
||||
</Step>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="being reachable" title="Let peers connect back.">
|
||||
Browsing and downloading are peer-to-peer, so at least one side has to accept an
|
||||
incoming connection.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Prose>
|
||||
<p>
|
||||
With the listener on (the default), soulseek-rs tries to open its listen port
|
||||
on your router via{' '}
|
||||
<b className="font-medium text-primary">UPnP-IGD</b> and{' '}
|
||||
<b className="font-medium text-primary">NAT-PMP</b>. It’s best-effort:
|
||||
if your router has those disabled it’s a no-op, and you forward the port
|
||||
yourself.
|
||||
</p>
|
||||
<p>
|
||||
The port is your <Code>--listener-port</Code> (env <Code>LISTENER_PORT</Code>,
|
||||
default <Code>2234</Code>); it’s renewed automatically and removed on
|
||||
exit. Pass <Code>--disable-listener</Code> to turn it off. Check your own
|
||||
network without launching the client:
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Terminal lines={[{ t: 'cmd', text: 'soulseek-rs portmap' }]} />
|
||||
</div>
|
||||
</Prose>
|
||||
<Callout tone="warn" title="honest about limits">
|
||||
<p>
|
||||
If both you and a peer are behind routers with no forwarded port, browsing
|
||||
that peer can’t work, that’s a fundamental Soulseek/peer-to-peer
|
||||
limitation, not a bug. Forward <Code>--listener-port</Code>, or let{' '}
|
||||
<Code>portmap</Code> try UPnP.
|
||||
</p>
|
||||
</Callout>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section id="platforms">
|
||||
<SectionHead eyebrow="platform notes" title="Where things live.">
|
||||
Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '}
|
||||
<Code>SOULSEEK_STATE_DIR</Code> to relocate either one, which also makes a
|
||||
portable install possible.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{PLATFORMS.map((platform) => (
|
||||
<div
|
||||
key={platform.id}
|
||||
id={platform.id}
|
||||
className="scroll-mt-20 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
||||
>
|
||||
<h3 className="mb-4 text-heading leading-[var(--text-heading--line-height)] font-medium">
|
||||
{platform.name}
|
||||
</h3>
|
||||
<dl className="grid grid-cols-1 gap-x-3.5 gap-y-0.5 sm:grid-cols-[auto_1fr] sm:gap-y-2">
|
||||
{(
|
||||
[
|
||||
['Password', platform.credentialStore, false],
|
||||
['Config', platform.config, true],
|
||||
['State', platform.state, true],
|
||||
['Downloads', platform.downloads, true],
|
||||
] as const
|
||||
).map(([label, value, mono]) => (
|
||||
<div key={label} className="contents">
|
||||
<dt className="pt-2 text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary sm:pt-0.5">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="min-w-0 text-[12.5px] leading-[19px] text-primary">
|
||||
{mono ? (
|
||||
<code className="font-mono text-xs break-all">{value}</code>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="mt-4 border-t border-hairline pt-3.5 text-[12.5px] leading-5 text-secondary">
|
||||
{platform.note}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Cols start className="mt-7">
|
||||
<FeatureCard title="Secrets">
|
||||
Your password goes to the OS keychain, never a plain-text file. If you would
|
||||
rather it never be stored at all, have <code>password_cmd</code> shell out to
|
||||
your own password manager and print it on demand.
|
||||
</FeatureCard>
|
||||
<FeatureCard title="Settings and state">
|
||||
<code>config.toml</code> is layered: flags beat environment variables, which
|
||||
beat the file, which beats the defaults. State (searches, downloads, rooms) is
|
||||
versioned JSON and restores on restart; anything unreadable is set aside as{' '}
|
||||
<code>.bak</code> instead of being overwritten.
|
||||
</FeatureCard>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
||||
Three things exist on disk: the binary, the config, and the state. Nothing else is
|
||||
written, and there is no telemetry to opt out of.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Terminal
|
||||
label="macOS · Linux"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||
{ t: 'cm', text: '# config, state and any cached shares' },
|
||||
{
|
||||
t: 'cmd',
|
||||
text: 'rm -rf ~/.config/soulseek-rs ~/.local/share/soulseek-rs',
|
||||
},
|
||||
{ t: 'cm', text: '# Linux: drop the saved password' },
|
||||
{ t: 'cmd', text: 'secret-tool clear service soulseek-rs' },
|
||||
]}
|
||||
/>
|
||||
<Terminal
|
||||
label="Windows · PowerShell"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||
{
|
||||
t: 'cmd',
|
||||
text: 'Remove-Item -Recurse -Force $env:APPDATA\\soulseek-rs',
|
||||
},
|
||||
{ t: 'cm', text: '# then remove the soulseek-rs entry from' },
|
||||
{ t: 'cm', text: '# Credential Manager > Windows Credentials' },
|
||||
]}
|
||||
/>
|
||||
</Cols>
|
||||
<div className="mt-5">
|
||||
<Callout title="two things it will not touch">
|
||||
<p>
|
||||
Your downloads and your shared directories are yours: they stay exactly where
|
||||
they are. On macOS, delete the keychain entry for <code>soulseek-rs</code> in
|
||||
Keychain Access if you saved a password.
|
||||
</p>
|
||||
</Callout>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Eyebrow, Wrap } from '@/components/ui/layout'
|
||||
import { Terminal } from '@/components/ui/terminal'
|
||||
|
||||
export const NotFound = () => {
|
||||
return (
|
||||
<section className="py-16">
|
||||
<Wrap>
|
||||
<div className="flex max-w-[560px] flex-col gap-3.5">
|
||||
<Eyebrow>404</Eyebrow>
|
||||
<h1 className="text-[26px] leading-[34px] font-medium sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
No such path.
|
||||
</h1>
|
||||
<p className="text-secondary">
|
||||
That page doesn’t exist. The four that do are in the nav above.
|
||||
</p>
|
||||
<Terminal
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'soulseek-rs --help' },
|
||||
{ t: 'cm', text: '# no such page: check the nav, or start here' },
|
||||
]}
|
||||
copy="soulseek-rs --help"
|
||||
/>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href="/" variant="accent">
|
||||
Home
|
||||
</Button>
|
||||
<Button href="/docs">Read the docs</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Wrap>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user