feat: serve web UI from relay + repos page redesign (#479)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-05 19:20:13 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4701bd6f32
commit 3a12369491
21 changed files with 492 additions and 252 deletions
-1
View File
@@ -1,6 +1,5 @@
target/
desktop/
web/
.git/
.scratch/
*.md
+5
View File
@@ -49,6 +49,11 @@ RELAY_URL=ws://localhost:3000
# Set to true in production to require bearer token authentication
SPROUT_REQUIRE_AUTH_TOKEN=false
# Optional: path to the web UI dist directory. When set, the relay serves
# the web frontend at / for browser requests. Leave unset for local dev
# (use `just web` for Vite HMR instead).
# SPROUT_WEB_DIR=./web/dist
# -----------------------------------------------------------------------------
# Auth
# -----------------------------------------------------------------------------
Generated
+27
View File
@@ -1530,6 +1530,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2115,6 +2121,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minidom"
version = "0.16.0"
@@ -4574,7 +4590,12 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
@@ -4701,6 +4722,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
+1 -1
View File
@@ -40,7 +40,7 @@ tokio-util = { version = "0.7", features = ["rt", "codec"] }
# HTTP + WebSocket
axum = { version = "0.8", features = ["ws", "macros"] }
tower = { version = "0.5", features = ["timeout", "util"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "fs"] }
# Database
sqlx = { version = "0.8", features = [
+13 -2
View File
@@ -1,4 +1,4 @@
# ── Build stage ──────────────────────────────────────────────
# ── Build stage (Rust) ──────────────────────────────────────
# Hard-code --platform to prevent exec format error on ARM Macs.
FROM --platform=linux/amd64 rust:1.93-bookworm AS builder
WORKDIR /build
@@ -6,7 +6,15 @@ COPY . .
RUN cargo build --release -p sprout-relay \
&& strip target/release/sprout-relay
# ── Runtime stage ────────────────────────────────────────────
# ── Web build stage (Node/pnpm) ────────────────────────────
FROM --platform=linux/amd64 node:22-bookworm-slim AS web-builder
WORKDIR /build
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY web/ web/
RUN corepack enable && pnpm install --frozen-lockfile --filter sprout-web
RUN pnpm -C web build
# ── Runtime stage ───────────────────────────────────────────
FROM --platform=linux/amd64 debian:bookworm-slim
# CAKE: non-root UID 1000 (numeric, not username)
@@ -20,9 +28,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates socat && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/sprout-relay /code/sprout-relay
COPY --from=web-builder /build/web/dist /code/web
COPY script/start /code/start
RUN chmod +x /code/start
ENV SPROUT_WEB_DIR="/code/web"
# CAKE: required Envoy env vars (overridden at runtime by CAKE).
ENV ENVOY_ADMIN_SOCKET_PATH="@envoy-admin.sock" \
ENVOY_INGRESS_PORT="20001" \
+27
View File
@@ -106,6 +106,12 @@ pub struct Config {
/// HMAC secret for git pre-receive hook callbacks.
/// Used to authenticate internal policy endpoint requests.
pub git_hook_hmac_secret: String,
// ── Web UI serving ────────────────────────────────────────────────────────
/// Optional path to the web UI `dist/` directory.
/// When set, the relay serves the SPA from this directory for browser requests.
/// When unset, no static file serving happens (relay behaves as before).
pub web_dir: Option<std::path::PathBuf>,
}
impl Config {
@@ -294,6 +300,26 @@ impl Config {
let secret: [u8; 32] = rand::random();
hex::encode(secret)
});
// Web UI static file serving
let web_dir = std::env::var("SPROUT_WEB_DIR")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
if let Some(ref dir) = web_dir {
if !dir.join("index.html").is_file() {
return Err(ConfigError::InvalidValue(format!(
"SPROUT_WEB_DIR={} does not contain index.html",
dir.display()
)));
}
tracing::info!(
"SPROUT_WEB_DIR={} — serving web UI from relay",
dir.display()
);
}
// Reject explicitly-configured secrets that are too short.
// The auto-generated fallback is always 64 hex chars (32 bytes), so this
// only fires when someone sets SPROUT_GIT_HOOK_HMAC_SECRET to a weak value.
@@ -332,6 +358,7 @@ impl Config {
git_max_repos_per_pubkey,
git_max_concurrent_ops,
git_hook_hmac_secret,
web_dir,
})
}
}
+52 -2
View File
@@ -14,6 +14,7 @@ use axum::{
use serde_json::json;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use crate::api;
@@ -76,10 +77,49 @@ pub fn build_router(state: Arc<AppState>) -> Router {
// Merge — each sub-router carries its own body limit.
// Metrics → Trace → CORS applied once over the combined router.
api_router
let mut merged = api_router
.merge(media_router)
.merge(git_router)
.merge(git_policy_router)
.merge(git_policy_router);
// When SPROUT_WEB_DIR is set, serve the SPA as a fallback for unmatched routes.
if let Some(ref web_dir) = state.config.web_dir {
let index_path = web_dir.join("index.html");
let spa_fallback = ServeDir::new(web_dir).not_found_service(tower::service_fn(
move |req: axum::extract::Request| {
let index = index_path.clone();
async move {
let path = req.uri().path();
// Reserved API prefixes must 404 normally, not serve index.html.
let reserved = path.starts_with("/api/")
|| path.starts_with("/media/")
|| path.starts_with("/git/")
|| path.starts_with("/internal/")
|| path.starts_with("/.well-known/")
|| path.starts_with("/huddle/")
|| path == "/health"
|| path == "/_liveness"
|| path == "/_readiness"
|| path == "/_status"
|| path == "/info";
// Files with extensions (e.g. /assets/missing.js) should 404.
let has_ext = path.rsplit('/').next().is_some_and(|seg| seg.contains('.'));
if reserved || has_ext {
Ok(StatusCode::NOT_FOUND.into_response())
} else {
// SPA client-side route → serve index.html
match tokio::fs::read(&index).await {
Ok(body) => Ok(axum::response::Html(body).into_response()),
Err(_) => Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
}
}
}
},
));
merged = merged.fallback_service(spa_fallback);
}
merged
.layer(middleware::from_fn(track_metrics))
.layer(TraceLayer::new_for_http())
.layer(build_cors_layer(&state.config.cors_origins))
@@ -129,6 +169,16 @@ async fn nip11_or_ws_handler(
.on_upgrade(move |socket| handle_connection(socket, state, addr))
.into_response(),
Err(_) => {
// Browser requesting HTML and web UI is configured → serve SPA.
if let Some(ref dir) = state.config.web_dir {
if accept.contains("text/html") {
let index = dir.join("index.html");
if let Ok(body) = tokio::fs::read(&index).await {
return axum::response::Html(body).into_response();
}
}
}
// Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
Json(info).into_response()
}
+8
View File
@@ -150,6 +150,14 @@ test-integration:
relay:
cargo run -p sprout-relay
# Start the relay with the built web UI served from it
relay-web:
#!/usr/bin/env bash
set -euo pipefail
[[ -d node_modules ]] || pnpm install
pnpm -C web build
SPROUT_WEB_DIR=./web/dist cargo run -p sprout-relay
# Start the relay server in release mode
relay-release:
cargo run -p sprout-relay --release
+2 -32
View File
@@ -1,36 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card";
import { ReposPage } from "@/features/repos/ui/ReposPage";
export const Route = createFileRoute("/")({
component: HomeRoute,
component: ReposPage,
});
function HomeRoute() {
const relayUrl = import.meta.env.VITE_RELAY_URL || "ws://localhost:3000";
return (
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Relay</CardTitle>
<CardDescription>Connected relay endpoint</CardDescription>
</CardHeader>
<CardContent>
<code
className="text-sm font-mono text-muted-foreground"
data-testid="relay-url"
>
{relayUrl}
</code>
</CardContent>
</Card>
</div>
);
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { ReposPage } from "@/features/repos/ui/ReposPage";
import { Navigate, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/repos")({
component: ReposPage,
component: () => <Navigate to="/" />,
});
+6 -20
View File
@@ -5,30 +5,16 @@ export const Route = createRootRoute({
component: RootLayout,
});
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
className="text-sm text-muted-foreground transition-colors hover:text-foreground [&.active]:font-medium [&.active]:text-foreground"
>
{children}
</Link>
);
}
function RootLayout() {
return (
<div className="flex min-h-dvh flex-col">
<header className="flex h-12 items-center justify-between border-b px-4">
<nav className="flex items-center gap-4">
<Link
to="/"
className="text-sm font-semibold tracking-tight text-foreground"
>
Sprout
</Link>
<NavLink to="/repos">Repos</NavLink>
</nav>
<Link
to="/"
className="text-sm font-semibold tracking-tight text-foreground"
>
Sprout
</Link>
<ThemeToggle />
</header>
<main className="flex flex-1 flex-col">
@@ -0,0 +1,17 @@
import { ExternalLink } from "lucide-react";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button";
export function ConnectButton({ className }: { className?: string }) {
const deepLink = `sprout://connect?relay=${encodeURIComponent(relayWsUrl())}`;
return (
<Button asChild className={className}>
<a href={deepLink}>
<ExternalLink className="h-4 w-4" />
Connect to Relay
</a>
</Button>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { Users } from "lucide-react";
import { useMemo } from "react";
import type { Repo } from "../use-repos";
import { ConnectButton } from "./ConnectButton";
const MAX_AVATARS = 20;
/** Simple hash of a hex pubkey to a hue value (0-360). */
function pubkeyToHue(hex: string): number {
let hash = 0;
for (let i = 0; i < hex.length; i++) {
hash = (hash * 31 + hex.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
function PubkeyAvatar({ pubkey }: { pubkey: string }) {
const hue = pubkeyToHue(pubkey);
return (
<div
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: `hsl(${hue}, 55%, 45%)` }}
title={pubkey}
>
{pubkey.slice(0, 2)}
</div>
);
}
export function OrgSidebar({ repos }: { repos: Repo[] }) {
const uniquePubkeys = useMemo(() => {
const set = new Set<string>();
for (const repo of repos) {
set.add(repo.owner);
for (const c of repo.contributors) {
set.add(c);
}
}
return [...set];
}, [repos]);
const visiblePubkeys = uniquePubkeys.slice(0, MAX_AVATARS);
const overflowCount = uniquePubkeys.length - MAX_AVATARS;
return (
<div className="space-y-6">
{/* Connect to Relay */}
<ConnectButton className="w-full" />
{/* People section */}
{uniquePubkeys.length > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-sidebar-foreground">
<Users className="h-4 w-4" />
People
</h3>
<div className="flex flex-wrap gap-2">
{visiblePubkeys.map((pk) => (
<PubkeyAvatar key={pk} pubkey={pk} />
))}
</div>
{overflowCount > 0 && (
<span className="mt-2 block text-xs text-muted-foreground">
{uniquePubkeys.length} people
</span>
)}
</div>
)}
</div>
);
}
-130
View File
@@ -1,130 +0,0 @@
import { Check, Copy, ExternalLink, GitBranch } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import type { Repo } from "../use-repos";
function truncateHex(hex: string): string {
if (hex.length <= 12) return hex;
return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
}
function formatDate(unix: number): string {
return new Date(unix * 1000).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function CopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value).then(
() => {
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
},
() => {
toast.error("Failed to copy to clipboard");
},
);
}, [value]);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={handleCopy}
aria-label={label}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
);
}
export function RepoCard({ repo }: { repo: Repo }) {
const relayUrl = relayWsUrl();
const deepLink = `sprout://connect?relay=${encodeURIComponent(relayUrl)}`;
return (
<Card className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start gap-2">
<GitBranch className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<CardTitle className="text-base">{repo.name}</CardTitle>
{repo.description && (
<CardDescription className="mt-1 line-clamp-2">
{repo.description}
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 space-y-3 text-sm">
{repo.cloneUrls.length > 0 && (
<div className="space-y-1.5">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Clone
</span>
{repo.cloneUrls.map((url) => (
<div key={url} className="flex items-center gap-1">
<code className="min-w-0 flex-1 truncate rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">
{url}
</code>
<CopyButton value={url} label={`Copy clone URL: ${url}`} />
</div>
))}
</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default font-mono">
{truncateHex(repo.owner)}
</span>
</TooltipTrigger>
<TooltipContent>{repo.owner}</TooltipContent>
</Tooltip>
<span>{formatDate(repo.createdAt)}</span>
</div>
</CardContent>
<CardFooter className="gap-2 border-t pt-4">
<Button asChild size="sm" className="flex-1">
<a href={deepLink} aria-label={`Open ${repo.name} in Sprout`}>
<ExternalLink className="h-3.5 w-3.5" />
Open in Sprout
</a>
</Button>
<CopyButton value={relayUrl} label="Copy relay URL" />
</CardFooter>
</Card>
);
}
@@ -0,0 +1,75 @@
import { BookMarked } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import type { Repo } from "../use-repos";
function truncateHex(hex: string): string {
if (hex.length <= 12) return hex;
return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
}
function relativeTime(unix: number): string {
const now = Date.now();
const diff = now - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
if (minutes > 0)
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
return "just now";
}
export function RepoListItem({ repo }: { repo: Repo }) {
return (
<div className="py-6">
{/* Row 1: Name + badge */}
<div className="flex items-center gap-2">
<BookMarked className="h-4 w-4 shrink-0 text-muted-foreground" />
{repo.webUrl ? (
<a
href={repo.webUrl}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-semibold text-primary hover:underline"
>
{repo.name}
</a>
) : (
<span className="text-lg font-semibold">{repo.name}</span>
)}
<Badge variant="outline" className="ml-1">
Public
</Badge>
</div>
{/* Row 2: Description */}
{repo.description && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{repo.description}
</p>
)}
{/* Row 3: Metadata */}
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default font-mono">
{truncateHex(repo.owner)}
</span>
</TooltipTrigger>
<TooltipContent>{repo.owner}</TooltipContent>
</Tooltip>
<span>Updated {relativeTime(repo.createdAt)}</span>
</div>
</div>
);
}
+123 -54
View File
@@ -1,58 +1,55 @@
import { GitBranch } from "lucide-react";
import { BookMarked, GitBranch } from "lucide-react";
import { toast } from "sonner";
import { useEffect } from "react";
import { useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";
import { useRepos } from "../use-repos";
import { RepoCard } from "./RepoCard";
import { ConnectButton } from "./ConnectButton";
import { OrgSidebar } from "./OrgSidebar";
import { RepoListItem } from "./RepoListItem";
function RepoCardSkeleton() {
type SortOrder = "newest" | "oldest" | "name";
function ListItemSkeleton() {
return (
<Card className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start gap-2">
<div className="mt-0.5 h-4 w-4 shrink-0 animate-pulse rounded bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-4 w-full animate-pulse rounded bg-muted" />
</div>
</div>
</CardHeader>
<CardContent className="flex-1 space-y-3">
<div className="space-y-1.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted" />
<div className="h-7 w-full animate-pulse rounded bg-muted" />
</div>
<div className="flex items-center justify-between">
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
</div>
</CardContent>
<div className="flex gap-2 border-t p-6 pt-4">
<div className="h-8 flex-1 animate-pulse rounded-md bg-muted" />
<div className="h-8 w-8 animate-pulse rounded-md bg-muted" />
<div className="py-6">
<div className="flex items-center gap-2">
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-muted" />
<div className="h-5 w-48 animate-pulse rounded bg-muted" />
<div className="h-5 w-14 animate-pulse rounded bg-muted" />
</div>
</Card>
<div className="mt-2 h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="mt-2 flex gap-4">
<div className="h-3 w-24 animate-pulse rounded bg-muted" />
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
</div>
</div>
);
}
function EmptyState() {
function EmptyState({ hasSearch }: { hasSearch: boolean }) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<GitBranch className="h-7 w-7 text-muted-foreground" />
</div>
<h2 className="mt-4 text-lg font-semibold">No repositories yet</h2>
<h2 className="mt-4 text-lg font-semibold">
{hasSearch ? "No matching repositories" : "No repositories yet"}
</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Repositories published to this relay will appear here. Push a git repo
using the Sprout desktop app to get started.
{hasSearch
? "Try adjusting your search term."
: "Repositories published to this relay will appear here. Push a git repo using the Sprout desktop app to get started."}
</p>
{!hasSearch && <ConnectButton className="mt-6" />}
</div>
);
}
export function ReposPage() {
const { data: repos, isLoading, error } = useRepos();
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortOrder>("newest");
useEffect(() => {
if (error) {
@@ -62,42 +59,114 @@ export function ReposPage() {
}
}, [error]);
const filteredRepos = useMemo(() => {
if (!repos) return [];
const term = search.toLowerCase();
let result = repos.filter(
(r) =>
r.name.toLowerCase().includes(term) ||
r.description.toLowerCase().includes(term),
);
switch (sort) {
case "newest":
result = result.sort((a, b) => b.createdAt - a.createdAt);
break;
case "oldest":
result = result.sort((a, b) => a.createdAt - b.createdAt);
break;
case "name":
result = result.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
break;
}
return result;
}, [repos, search, sort]);
if (isLoading) {
return (
<div className="mx-auto w-full max-w-6xl px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
Repositories
</h1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{["a", "b", "c", "d", "e", "f"].map((key) => (
<RepoCardSkeleton key={key} />
))}
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
<div className="min-w-0 flex-1">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<BookMarked className="h-5 w-5" /> Repositories
</h2>
<div className="divide-y">
{["a", "b", "c", "d", "e"].map((key) => (
<ListItemSkeleton key={key} />
))}
</div>
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
if (!repos || repos.length === 0) {
return (
<div className="mx-auto w-full max-w-6xl px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
Repositories
</h1>
<EmptyState />
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
<div className="min-w-0 flex-1">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<BookMarked className="h-5 w-5" /> Repositories
</h2>
<EmptyState hasSearch={false} />
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
return (
<div className="mx-auto w-full max-w-6xl px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
Repositories
</h1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{repos.map((repo) => (
<RepoCard key={repo.id} repo={repo} />
))}
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
{/* Main content */}
<div className="min-w-0 flex-1">
{/* Mobile-only connect button */}
<div className="mb-4 lg:hidden">
<ConnectButton className="w-full" />
</div>
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<BookMarked className="h-5 w-5" /> Repositories
</h2>
{/* Search + Sort bar */}
<div className="mb-4 flex gap-3">
<Input
placeholder="Find a repository..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOrder)}
aria-label="Sort repositories"
className="rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="name">Name</option>
</select>
</div>
{/* Repo list */}
{filteredRepos.length > 0 ? (
<div className="divide-y">
{filteredRepos.map((repo) => (
<RepoListItem key={repo.id} repo={repo} />
))}
</div>
) : (
<EmptyState hasSearch={search.length > 0} />
)}
</div>
{/* Sidebar */}
<aside className="hidden w-72 shrink-0 border-l border-border pl-8 lg:block">
<OrgSidebar repos={repos} />
</aside>
</div>
);
}
+4 -1
View File
@@ -9,6 +9,7 @@ export interface Repo {
cloneUrls: string[];
webUrl: string | null;
owner: string;
contributors: string[];
createdAt: number;
}
@@ -28,7 +29,8 @@ function eventToRepo(event: NostrEvent): Repo {
const description = getTag(event, "description") || event.content || "";
const cloneUrls = getAllTags(event, "clone");
const webUrl = getTag(event, "web") ?? null;
const owner = getAllTags(event, "p")[0] ?? event.pubkey;
const contributors = getAllTags(event, "p");
const owner = event.pubkey;
return {
id: d,
@@ -37,6 +39,7 @@ function eventToRepo(event: NostrEvent): Repo {
cloneUrls,
webUrl,
owner,
contributors,
createdAt: event.created_at,
};
}
+6 -2
View File
@@ -9,9 +9,13 @@ export function relayHttpUrl(wsUrl: string): string {
return wsUrl;
}
/** Read the relay WebSocket URL from environment or fall back to localhost. */
/** Read the relay WebSocket URL from environment or derive from window.location. */
export function relayWsUrl(): string {
return import.meta.env.VITE_RELAY_URL || "ws://localhost:3000";
const envUrl = import.meta.env.VITE_RELAY_URL;
if (envUrl) return envUrl;
// Same-origin: derive from current page location (works when served from relay)
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}`;
}
/** HTTP base URL for the relay (derived from the WS URL). */
+30
View File
@@ -0,0 +1,30 @@
import { cn } from "@/shared/lib/cn";
import { type VariantProps, cva } from "class-variance-authority";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
},
);
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+20
View File
@@ -0,0 +1,20 @@
import { cn } from "@/shared/lib/cn";
import { type InputHTMLAttributes, forwardRef } from "react";
const Input = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
));
Input.displayName = "Input";
export { Input };
+2 -4
View File
@@ -5,9 +5,7 @@ test("home page loads with Sprout heading", async ({ page }) => {
await expect(page.locator("header")).toContainText("Sprout");
});
test("relay URL is visible", async ({ page }) => {
test("home page shows repositories section", async ({ page }) => {
await page.goto("/");
const relayUrl = page.getByTestId("relay-url");
await expect(relayUrl).toBeVisible();
await expect(relayUrl).toContainText("ws://");
await expect(page.getByText("Repositories")).toBeVisible();
});