feat(ui): add "How do I use this?" usage instructions to repo detail pages #105

Merged
benvin merged 1 commits from benvin/ui-usage-instructions into master 2026-07-25 14:16:23 +10:00
5 changed files with 402 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
.usage-panel {
margin: 24px 0;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.usage-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 14px 18px;
background: transparent;
border: none;
color: var(--text-bright);
font-size: 0.95em;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.usage-toggle:hover {
background: var(--bg-elevated);
}
.usage-caret {
display: inline-block;
transition: transform 0.15s;
color: var(--text-muted);
font-size: 0.9em;
}
.usage-caret.open {
transform: rotate(90deg);
}
.usage-body {
padding: 4px 18px 18px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 18px;
}
.usage-snippet-title {
font-size: 0.85em;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
margin: 14px 0 8px;
}
.usage-codebox {
position: relative;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.usage-codebox pre {
margin: 0;
padding: 14px 16px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.85em;
line-height: 1.5;
color: var(--text-bright);
white-space: pre;
}
.usage-copy-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 3px 10px;
font-size: 0.75em;
font-family: var(--font-sans);
color: var(--text-muted);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.usage-copy-btn:hover {
color: var(--text-bright);
border-color: var(--accent);
}
.usage-note {
margin-top: 8px;
font-size: 0.82em;
color: var(--text-muted);
line-height: 1.45;
}
+290
View File
@@ -0,0 +1,290 @@
import { useState } from 'react';
import './UsageInstructions.css';
// repoClass distinguishes the three ways a repository is consumed. remotes are
// caching proxies, locals are real registries you also publish to, virtuals are
// merged read-only indexes.
type RepoClass = 'remote' | 'local' | 'virtual';
interface Snippet {
title: string;
language: string;
code: string;
note?: string;
}
// baseURL resolves the externally reachable origin of this artifactapi instance.
// The UI is served on the same origin as the API (client BASE is ''), so
// window.location.origin is the address a host would actually curl/pull against
// — no hardcoded hostname, works in prod and in `npm run dev` behind a proxy.
function baseURL(): string {
if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin.replace(/\/$/, '');
}
return 'https://artifactapi.k8s.syd1.au.unkin.net';
}
// hostOnly is the bare host[:port] with no scheme, for docker/terraform source
// addresses which are scheme-less.
function hostOnly(): string {
try {
return new URL(baseURL()).host;
} catch {
return 'artifactapi.k8s.syd1.au.unkin.net';
}
}
// remoteProxyBase is where a remote (or virtual) repo's proxied artifacts live.
function remoteProxyBase(cls: RepoClass, name: string): string {
const seg = cls === 'virtual' ? 'virtual' : 'remote';
return `${baseURL()}/api/v1/${seg}/${name}`;
}
export function buildSnippets(packageType: string, repoClass: RepoClass, name: string): Snippet[] {
const url = baseURL();
const host = hostOnly();
const proxy = remoteProxyBase(repoClass, name);
const isLocal = repoClass === 'local';
switch (packageType) {
case 'rpm':
return [
{
title: isLocal
? 'Add the yum repo (real yum repo, repodata auto-regenerated)'
: 'Add the yum repo (caching proxy)',
language: 'bash',
code: `sudo tee /etc/yum.repos.d/${name}.repo >/dev/null <<'EOF'
[${name}]
name=${name} (artifactapi)
baseurl=${isLocal ? `${url}/api/v2/remotes/${name}/files/` : `${proxy}/`}
enabled=1
gpgcheck=0
repo_gpgcheck=0
EOF
sudo dnf install <package>`,
note: isLocal
? 'gpgcheck=0: artifactapi serves the repo unsigned. If you sign your RPMs, import your key and set gpgcheck=1.'
: 'gpgcheck=0 trusts upstream over the proxy. To verify package signatures, import the upstream GPG key and set gpgcheck=1.',
},
...(isLocal
? [
{
title: 'Publish an RPM (repodata regenerates automatically)',
language: 'bash',
code: `curl -fsSL --upload-file ./my-package-1.0-1.el9.x86_64.rpm \\
${url}/api/v2/remotes/${name}/files/my-package-1.0-1.el9.x86_64.rpm`,
},
]
: []),
];
case 'pypi':
return [
{
title: 'Install a package (one-off)',
language: 'bash',
code: `pip install --index-url ${proxy}/simple/ <package>`,
},
{
title: 'Configure pip persistently',
language: 'bash',
code: `mkdir -p ~/.config/pip
cat > ~/.config/pip/pip.conf <<'EOF'
[global]
index-url = ${proxy}/simple/
EOF
pip install <package>`,
},
];
case 'npm':
return [
{
title: 'Point npm at this registry',
language: 'bash',
code: `npm config set registry ${proxy}/
npm install <package>`,
},
{
title: 'Per-project (.npmrc)',
language: 'bash',
code: `echo 'registry=${proxy}/' >> .npmrc
npm install`,
},
];
case 'docker':
return [
{
title: 'Pull an image',
language: 'bash',
code: `docker pull ${host}/${name}/<image>:<tag>`,
note: 'The first path segment after the host is the artifactapi repo name; the rest is the image name.',
},
...(isLocal
? [
{
title: 'Push an image (this is a real Registry V2)',
language: 'bash',
code: `docker tag myapp:latest ${host}/${name}/myapp:latest
docker push ${host}/${name}/myapp:latest`,
note: 'Works with docker, podman, skopeo and buildah. If the registry requires auth, run `docker login ' + host + '` first.',
},
]
: []),
];
case 'terraform':
return [
{
title: 'Use as a provider source (bare address, no mirror config)',
language: 'hcl',
code: `terraform {
required_providers {
${name} = {
source = "${host}/${name}/<type>"
version = ">= 0.1.0"
}
}
}`,
note: 'The namespace segment is this repo name; <type> is the provider type. artifactapi signs SHA256SUMS server-side with its GPG key, so `terraform init` installs with no .terraformrc.',
},
...(isLocal
? [
{
title: 'Publish a provider build',
language: 'bash',
code: `curl -fsSL --upload-file terraform-provider-<type>_0.1.0_linux_amd64.zip \\
${url}/api/v2/remotes/${name}/files/${name}/<type>/terraform-provider-<type>_0.1.0_linux_amd64.zip`,
},
]
: []),
];
case 'helm':
return [
{
title: 'Add the Helm repo',
language: 'bash',
code: `helm repo add ${name} ${proxy}/
helm repo update
helm install <release> ${name}/<chart>`,
},
];
case 'alpine':
return [
{
title: 'Add the APK repository',
language: 'bash',
code: `echo '${proxy}/' | sudo tee -a /etc/apk/repositories
sudo apk update
sudo apk add <package>`,
note: 'If the index is unsigned over the proxy, add --allow-untrusted or install the signing key into /etc/apk/keys.',
},
];
case 'goproxy':
return [
{
title: 'Point the Go module proxy here',
language: 'bash',
code: `export GOPROXY=${proxy}
go mod download`,
note: 'Append ,direct to fall back to VCS for modules this proxy does not cover.',
},
];
case 'puppet':
return [
{
title: 'Install a module from the Forge proxy',
language: 'bash',
code: `puppet module install <author>-<module> \\
--module_repository ${proxy}`,
},
];
case 'generic':
default:
return [
{
title: 'Download a file',
language: 'bash',
code: `curl -fsSLO ${proxy}/<path>`,
note:
packageType === 'generic'
? 'Generic repos are fetched as plain files at their upstream path.'
: `No tailored client instructions for "${packageType}" yet — fetch artifacts directly by path.`,
},
...(isLocal
? [
{
title: 'Publish a file',
language: 'bash',
code: `curl -fsSL --upload-file ./myfile \\
${url}/api/v2/remotes/${name}/files/<path>/myfile`,
},
]
: []),
];
}
}
function CodeBox({ snippet }: { snippet: Snippet }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(snippet.code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API unavailable (e.g. non-secure context); silently ignore.
}
}
return (
<div className="usage-snippet">
<div className="usage-snippet-title">{snippet.title}</div>
<div className="usage-codebox">
<button className="usage-copy-btn" onClick={copy} type="button">
{copied ? 'copied' : 'copy'}
</button>
<pre className="mono">{snippet.code}</pre>
</div>
{snippet.note && <div className="usage-note">{snippet.note}</div>}
</div>
);
}
interface UsageInstructionsProps {
packageType: string;
repoClass: RepoClass;
name: string;
defaultOpen?: boolean;
}
export function UsageInstructions({ packageType, repoClass, name, defaultOpen = false }: UsageInstructionsProps) {
const [open, setOpen] = useState(defaultOpen);
const snippets = buildSnippets(packageType, repoClass, name);
return (
<div className="usage-panel">
<button className="usage-toggle" onClick={() => setOpen(o => !o)} type="button" aria-expanded={open}>
<span className={`usage-caret ${open ? 'open' : ''}`}>&#9656;</span>
How do I use this?
</button>
{open && (
<div className="usage-body">
{snippets.map((s, i) => (
<CodeBox key={i} snippet={s} />
))}
</div>
)}
</div>
);
}
+3
View File
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
import { api } from '../api/client';
import type { Remote } from '../api/types';
import { Badge } from '../components/Badge';
import { UsageInstructions } from '../components/UsageInstructions';
import './RemoteDetail.css';
export function LocalDetail() {
@@ -36,6 +37,8 @@ export function LocalDetail() {
<p className="detail-description">{remote.description}</p>
)}
<UsageInstructions packageType={remote.package_type} repoClass="local" name={remote.name} />
<div className="detail-actions">
<Link to={`/locals/${remote.name}/objects`} className="btn btn-primary">
Browse Files
+3
View File
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
import { api } from '../api/client';
import type { Remote } from '../api/types';
import { Badge } from '../components/Badge';
import { UsageInstructions } from '../components/UsageInstructions';
import './RemoteDetail.css';
export function RemoteDetail() {
@@ -109,6 +110,8 @@ export function RemoteDetail() {
)}
</div>
<UsageInstructions packageType={remote.package_type} repoClass="remote" name={remote.name} />
<div className="detail-actions">
<Link to={`/remotes/${remote.name}/objects`} className="btn btn-primary">
Browse Objects
+7
View File
@@ -4,6 +4,7 @@ import { api } from '../api/client';
import type { Remote, Virtual } from '../api/types';
import { Badge } from '../components/Badge';
import { DataTable } from '../components/DataTable';
import { UsageInstructions } from '../components/UsageInstructions';
import './Virtuals.css';
export function Virtuals() {
@@ -98,6 +99,12 @@ export function Virtuals() {
);
})}
</ul>
{(() => {
const v = virtuals.find(x => x.name === expanded);
return v ? (
<UsageInstructions packageType={v.package_type} repoClass="virtual" name={v.name} defaultOpen />
) : null;
})()}
</div>
)}
</div>