mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -1,6 +1,5 @@
|
||||
target/
|
||||
desktop/
|
||||
web/
|
||||
.git/
|
||||
.scratch/
|
||||
*.md
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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" \
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="/" />,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user