diff --git a/.dockerignore b/.dockerignore index d6a412ea..8ccc08e5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ target/ desktop/ -web/ .git/ .scratch/ *.md diff --git a/.env.example b/.env.example index 7a9c0970..502d5827 100644 --- a/.env.example +++ b/.env.example @@ -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 # ----------------------------------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index 9d0288f7..f171680d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 25339baf..47f2ef72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [ diff --git a/Dockerfile b/Dockerfile index f1994fe5..bd41102a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" \ diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index bc5062bb..ee0dcd69 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -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, } 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, }) } } diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 6dee0876..eed52be7 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -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) -> 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() } diff --git a/justfile b/justfile index 7c890da0..3d7d1710 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/web/src/app/routes/index.tsx b/web/src/app/routes/index.tsx index cb86cbab..a118f25d 100644 --- a/web/src/app/routes/index.tsx +++ b/web/src/app/routes/index.tsx @@ -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 ( -
- - - Relay - Connected relay endpoint - - - - {relayUrl} - - - -
- ); -} diff --git a/web/src/app/routes/repos.tsx b/web/src/app/routes/repos.tsx index 86f1d40a..b58d39ad 100644 --- a/web/src/app/routes/repos.tsx +++ b/web/src/app/routes/repos.tsx @@ -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: () => , }); diff --git a/web/src/app/routes/root.tsx b/web/src/app/routes/root.tsx index 8965e71b..9d8ff1c3 100644 --- a/web/src/app/routes/root.tsx +++ b/web/src/app/routes/root.tsx @@ -5,30 +5,16 @@ export const Route = createRootRoute({ component: RootLayout, }); -function NavLink({ to, children }: { to: string; children: React.ReactNode }) { - return ( - - {children} - - ); -} - function RootLayout() { return (
- + + Sprout +
diff --git a/web/src/features/repos/ui/ConnectButton.tsx b/web/src/features/repos/ui/ConnectButton.tsx new file mode 100644 index 00000000..d4d7c620 --- /dev/null +++ b/web/src/features/repos/ui/ConnectButton.tsx @@ -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 ( + + ); +} diff --git a/web/src/features/repos/ui/OrgSidebar.tsx b/web/src/features/repos/ui/OrgSidebar.tsx new file mode 100644 index 00000000..18b0a578 --- /dev/null +++ b/web/src/features/repos/ui/OrgSidebar.tsx @@ -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 ( +
+ {pubkey.slice(0, 2)} +
+ ); +} + +export function OrgSidebar({ repos }: { repos: Repo[] }) { + const uniquePubkeys = useMemo(() => { + const set = new Set(); + 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 ( +
+ {/* Connect to Relay */} + + + {/* People section */} + {uniquePubkeys.length > 0 && ( +
+

+ + People +

+
+ {visiblePubkeys.map((pk) => ( + + ))} +
+ {overflowCount > 0 && ( + + {uniquePubkeys.length} people + + )} +
+ )} +
+ ); +} diff --git a/web/src/features/repos/ui/RepoCard.tsx b/web/src/features/repos/ui/RepoCard.tsx deleted file mode 100644 index 1e45e5a7..00000000 --- a/web/src/features/repos/ui/RepoCard.tsx +++ /dev/null @@ -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 ( - - - - - Copy - - ); -} - -export function RepoCard({ repo }: { repo: Repo }) { - const relayUrl = relayWsUrl(); - const deepLink = `sprout://connect?relay=${encodeURIComponent(relayUrl)}`; - - return ( - - -
- -
- {repo.name} - {repo.description && ( - - {repo.description} - - )} -
-
-
- - - {repo.cloneUrls.length > 0 && ( -
- - Clone - - {repo.cloneUrls.map((url) => ( -
- - {url} - - -
- ))} -
- )} - -
- - - - {truncateHex(repo.owner)} - - - {repo.owner} - - {formatDate(repo.createdAt)} -
-
- - - - - -
- ); -} diff --git a/web/src/features/repos/ui/RepoListItem.tsx b/web/src/features/repos/ui/RepoListItem.tsx new file mode 100644 index 00000000..8ee16c4b --- /dev/null +++ b/web/src/features/repos/ui/RepoListItem.tsx @@ -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 ( +
+ {/* Row 1: Name + badge */} +
+ + {repo.webUrl ? ( + + {repo.name} + + ) : ( + {repo.name} + )} + + Public + +
+ + {/* Row 2: Description */} + {repo.description && ( +

+ {repo.description} +

+ )} + + {/* Row 3: Metadata */} +
+ + + + {truncateHex(repo.owner)} + + + {repo.owner} + + Updated {relativeTime(repo.createdAt)} +
+
+ ); +} diff --git a/web/src/features/repos/ui/ReposPage.tsx b/web/src/features/repos/ui/ReposPage.tsx index f6e6b369..0b96ca95 100644 --- a/web/src/features/repos/ui/ReposPage.tsx +++ b/web/src/features/repos/ui/ReposPage.tsx @@ -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 ( - - -
-
-
-
-
-
-
- - -
-
-
-
-
-
-
-
- -
-
-
+
+
+
+
+
+
+
+
+
+
- +
); } -function EmptyState() { +function EmptyState({ hasSearch }: { hasSearch: boolean }) { return (
-

No repositories yet

+

+ {hasSearch ? "No matching repositories" : "No repositories yet"} +

- 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."}

+ {!hasSearch && }
); } export function ReposPage() { const { data: repos, isLoading, error } = useRepos(); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("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 ( -
-

- Repositories -

-
- {["a", "b", "c", "d", "e", "f"].map((key) => ( - - ))} +
+
+

+ Repositories +

+
+ {["a", "b", "c", "d", "e"].map((key) => ( + + ))} +
+
); } if (!repos || repos.length === 0) { return ( -
-

- Repositories -

- +
+
+

+ Repositories +

+ +
+
); } return ( -
-

- Repositories -

-
- {repos.map((repo) => ( - - ))} +
+ {/* Main content */} +
+ {/* Mobile-only connect button */} +
+ +
+ +

+ Repositories +

+ + {/* Search + Sort bar */} +
+ setSearch(e.target.value)} + className="flex-1" + /> + +
+ + {/* Repo list */} + {filteredRepos.length > 0 ? ( +
+ {filteredRepos.map((repo) => ( + + ))} +
+ ) : ( + 0} /> + )}
+ + {/* Sidebar */} +
); } diff --git a/web/src/features/repos/use-repos.ts b/web/src/features/repos/use-repos.ts index a1eb7089..79077aa5 100644 --- a/web/src/features/repos/use-repos.ts +++ b/web/src/features/repos/use-repos.ts @@ -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, }; } diff --git a/web/src/shared/lib/relay-url.ts b/web/src/shared/lib/relay-url.ts index f0a334c5..4b2c25d2 100644 --- a/web/src/shared/lib/relay-url.ts +++ b/web/src/shared/lib/relay-url.ts @@ -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). */ diff --git a/web/src/shared/ui/badge.tsx b/web/src/shared/ui/badge.tsx new file mode 100644 index 00000000..b76a601d --- /dev/null +++ b/web/src/shared/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/web/src/shared/ui/input.tsx b/web/src/shared/ui/input.tsx new file mode 100644 index 00000000..d1fbef91 --- /dev/null +++ b/web/src/shared/ui/input.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/shared/lib/cn"; +import { type InputHTMLAttributes, forwardRef } from "react"; + +const Input = forwardRef< + HTMLInputElement, + InputHTMLAttributes +>(({ className, type, ...props }, ref) => ( + +)); +Input.displayName = "Input"; + +export { Input }; diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index d2f36ef4..5606c5d8 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -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(); });