tr]:last:border-b-0', className)}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
+ return (
+ [role=checkbox]]:translate-y-[2px]',
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]',
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+};
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..f57fffd
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,53 @@
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/src/hooks/useRepositories.ts b/src/hooks/useRepositories.ts
new file mode 100644
index 0000000..28d9991
--- /dev/null
+++ b/src/hooks/useRepositories.ts
@@ -0,0 +1,81 @@
+import { useState, useEffect, useMemo } from 'react';
+import { fetchAllRepos, invalidateReposCache, mapApiRepo } from '../lib/api';
+import type { ApiRepo } from '../lib/api';
+import type { Repository } from '../types/repo';
+import { DEFAULT_PER_PAGE } from '../lib/mock-data';
+
+interface Options {
+ page: number;
+ perPage?: number;
+ search?: string;
+ refreshKey?: number;
+}
+
+interface Result {
+ repos: Repository[];
+ totalCount: number;
+ totalPages: number;
+ windowStart: number;
+ windowEnd: number;
+ loading: boolean;
+ error: string | null;
+}
+
+export function useRepositories({
+ page,
+ perPage = DEFAULT_PER_PAGE,
+ search = '',
+ refreshKey = 0,
+}: Options): Result {
+ const [apiRepos, setApiRepos] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ if (refreshKey > 0) invalidateReposCache();
+ setLoading(true);
+ setError(null);
+ fetchAllRepos()
+ .then(data => {
+ if (!cancelled) {
+ setApiRepos(data);
+ setLoading(false);
+ }
+ })
+ .catch(err => {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : 'Failed to load repositories');
+ setLoading(false);
+ }
+ });
+ return () => { cancelled = true; };
+ }, [refreshKey]);
+
+ const derived = useMemo(() => {
+ if (!apiRepos) {
+ return { repos: [], totalCount: 0, totalPages: 1, windowStart: 0, windowEnd: 0 };
+ }
+ const q = search.trim().toLowerCase();
+ const filtered = q
+ ? apiRepos.filter(
+ r =>
+ r.name.toLowerCase().includes(q) ||
+ r.description.toLowerCase().includes(q) ||
+ r.owner_did.toLowerCase().includes(q),
+ )
+ : apiRepos;
+
+ const totalCount = filtered.length;
+ const totalPages = Math.max(1, Math.ceil(totalCount / perPage));
+ const safePage = Math.min(page, totalPages);
+ const start = (safePage - 1) * perPage;
+ const repos = filtered.slice(start, start + perPage).map(r => mapApiRepo(r));
+ const windowStart = totalCount === 0 ? 0 : start + 1;
+ const windowEnd = Math.min(start + perPage, totalCount);
+
+ return { repos, totalCount, totalPages, windowStart, windowEnd };
+ }, [apiRepos, page, perPage, search]);
+
+ return { ...derived, loading, error };
+}
diff --git a/src/hooks/useRepository.ts b/src/hooks/useRepository.ts
new file mode 100644
index 0000000..63293d1
--- /dev/null
+++ b/src/hooks/useRepository.ts
@@ -0,0 +1,51 @@
+import { useState, useEffect } from 'react';
+import { fetchRepo, fetchTree, fetchCommits, mapApiRepo } from '../lib/api';
+import type { Repository } from '../types/repo';
+
+interface Result {
+ repo: Repository | null;
+ notFound: boolean;
+ loading: boolean;
+ error: string | null;
+}
+
+export function useRepository(owner: string, name: string): Result {
+ const [repo, setRepo] = useState(null);
+ const [notFound, setNotFound] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!owner || !name) return;
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ setLoading(true);
+ setNotFound(false);
+ setError(null);
+ setRepo(null);
+
+ // Requests are sequential because the upstream limits concurrent connections.
+ (async () => {
+ try {
+ const apiRepo = await fetchRepo(owner, name, signal);
+ const treeEntries = await fetchTree(owner, name, signal);
+ const commits = await fetchCommits(owner, name, signal);
+ setRepo(mapApiRepo(apiRepo, commits, treeEntries));
+ setLoading(false);
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') return;
+ if (err instanceof Error && err.message === 'not_found') {
+ setNotFound(true);
+ } else {
+ setError(err instanceof Error ? err.message : 'Failed to load repository');
+ }
+ setLoading(false);
+ }
+ })();
+
+ return () => { controller.abort(); };
+ }, [owner, name]);
+
+ return { repo, notFound, loading, error };
+}
diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts
new file mode 100644
index 0000000..8b187ff
--- /dev/null
+++ b/src/hooks/useTheme.ts
@@ -0,0 +1,24 @@
+import { useState, useEffect } from 'react';
+
+type Theme = 'dark' | 'light';
+
+function getInitialTheme(): Theme {
+ if (typeof window === 'undefined') return 'dark';
+ const stored = localStorage.getItem('theme') as Theme | null;
+ if (stored === 'dark' || stored === 'light') return stored;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+export function useTheme() {
+ const [theme, setTheme] = useState(getInitialTheme);
+
+ useEffect(() => {
+ const root = document.documentElement;
+ root.classList.toggle('dark', theme === 'dark');
+ localStorage.setItem('theme', theme);
+ }, [theme]);
+
+ const toggle = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'));
+
+ return { theme, toggle };
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..5438edd
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,168 @@
+@import "tailwindcss";
+
+/* Class-based dark mode — toggled by adding/removing .dark on */
+@custom-variant dark (&:is(.dark, .dark *));
+
+/* ── ShadCN theme bridge ────────────────────────────────────────────────────── */
+/* Maps project CSS variables to Tailwind v4 utility classes.
+ @theme inline keeps values as var() refs so dark-mode overrides resolve at runtime. */
+@theme inline {
+ --color-background: var(--color-background);
+ --color-foreground: var(--color-foreground);
+ --color-surface: var(--color-surface);
+ --color-muted: var(--color-muted);
+ --color-muted-foreground: var(--color-muted-foreground);
+ --color-border: var(--color-border);
+ --color-accent: var(--color-accent);
+ --color-accent-foreground: var(--color-foreground);
+ /* ShadCN card */
+ --color-card: var(--color-surface);
+ --color-card-foreground: var(--color-foreground);
+ /* ShadCN primary — white text on dark */
+ --color-primary: var(--color-foreground);
+ --color-primary-foreground: var(--color-background);
+ /* ShadCN secondary */
+ --color-secondary: var(--color-muted);
+ --color-secondary-foreground: var(--color-foreground);
+ /* ShadCN destructive */
+ --color-destructive: #ef4444;
+ --color-destructive-foreground: #fafafa;
+ /* ShadCN popover */
+ --color-popover: var(--color-surface);
+ --color-popover-foreground: var(--color-foreground);
+ /* ShadCN input / ring */
+ --color-input: var(--color-border);
+ --color-ring: var(--color-accent);
+ /* radius */
+ --radius: 0.5rem;
+}
+
+/* ── Design tokens ─────────────────────────────────────────────────────────── */
+
+:root {
+ /* Light mode */
+ --color-background: #ffffff;
+ --color-foreground: #09090b;
+ --color-surface: #fafafa;
+ --color-surface-2: #f4f4f5;
+ --color-muted: #f4f4f5;
+ --color-muted-foreground: #71717a;
+ --color-border: #e4e4e7;
+ --color-border-inner: #efefef;
+ --color-text-primary: #09090b;
+ --color-text-mid: #52525b;
+ --color-text-muted: #71717a;
+ --color-text-dim: #a1a1aa;
+ --color-text-faint: #d4d4d8;
+ --color-warm: #c9a96e;
+ --color-warm-text: #a07840;
+ --color-accent: #5e6ad2;
+ --color-dot: rgba(0, 0, 0, 0.04);
+ --color-hover: rgba(0, 0, 0, 0.025);
+ --color-status-dot: #d4d4d8;
+}
+
+.dark {
+ --color-background: #09090b;
+ --color-foreground: #fafafa;
+ --color-surface: #111113;
+ --color-surface-2: #0c0c10;
+ --color-muted: #27272a;
+ --color-muted-foreground: #888898;
+ --color-border: #1f1f23;
+ --color-border-inner: #141418;
+ --color-text-primary: #fafafa;
+ --color-text-mid: #a1a1aa;
+ --color-text-muted: #888898;
+ --color-text-dim: #555568;
+ --color-text-faint: #3a3a42;
+ --color-warm: #c9a96e;
+ --color-warm-text: #c9a96e;
+ --color-accent: #5e6ad2;
+ --color-dot: rgba(255, 255, 255, 0.038);
+ --color-hover: rgba(255, 255, 255, 0.02);
+ --color-status-dot: #252530;
+}
+
+/* ── Base ──────────────────────────────────────────────────────────────────── */
+
+*, *::before, *::after {
+ box-sizing: border-box;
+ border-color: var(--color-border);
+}
+
+html, body {
+ height: 100%;
+ margin: 0;
+}
+
+html {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11", "rlig" 1, "calt" 1;
+ letter-spacing: -0.011em;
+}
+
+body {
+ background-color: var(--color-background);
+ color: var(--color-foreground);
+ font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 1.6;
+}
+
+#root {
+ min-height: 100vh;
+}
+
+::selection {
+ background: rgba(94, 106, 210, 0.25);
+}
+
+::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 999px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-border);
+ filter: brightness(1.2);
+}
+
+/* ── Animations ────────────────────────────────────────────────────────────── */
+
+@keyframes fadeUp {
+ from { opacity: 0; transform: translateY(5px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.animate-fade-up {
+ animation: fadeUp 0.18s ease-out both;
+}
+
+.animate-fade-in {
+ animation: fadeIn 0.15s ease-out both;
+}
+
+.animate-spin-icon {
+ animation: spin 0.7s linear infinite;
+ display: inline-block;
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..7b8bd01
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,237 @@
+const BASE_URL = '/api/v1';
+
+export interface ApiRepo {
+ id: string;
+ name: string;
+ owner_did: string;
+ description: string;
+ is_public: boolean;
+ default_branch: string;
+ clone_url: string;
+ star_count: number;
+ created_at: string;
+ updated_at: string;
+ forked_from: string | null;
+}
+
+export interface ApiTreeEntry {
+ hash: string;
+ mode: string;
+ name: string;
+ size: number | null;
+ type: 'blob' | 'tree';
+}
+
+export interface ApiCommit {
+ author: string;
+ date: string;
+ hash: string;
+ message: string;
+}
+
+let allReposCache: ApiRepo[] | null = null;
+let fetchAllPromise: Promise | null = null;
+
+export function invalidateReposCache() {
+ allReposCache = null;
+ fetchAllPromise = null;
+}
+
+export function fetchAllRepos(): Promise {
+ if (allReposCache !== null) return Promise.resolve(allReposCache);
+ if (!fetchAllPromise) {
+ fetchAllPromise = fetch(`${BASE_URL}/repos`)
+ .then(res => {
+ if (!res.ok) throw new Error(`Failed to fetch repos: ${res.status}`);
+ return res.json() as Promise;
+ })
+ .then(data => {
+ allReposCache = data;
+ fetchAllPromise = null;
+ return data;
+ })
+ .catch(err => {
+ fetchAllPromise = null;
+ throw err;
+ });
+ }
+ return fetchAllPromise;
+}
+
+export async function fetchRepo(owner: string, name: string, signal?: AbortSignal): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
+ { signal },
+ );
+ if (res.status === 404) throw new Error('not_found');
+ if (!res.ok) throw new Error(`Failed to fetch repo: ${res.status}`);
+ const data = await res.json();
+ if (data.error === 'repo_not_found') throw new Error('not_found');
+ return data as ApiRepo;
+}
+
+export async function fetchTree(owner: string, name: string, signal?: AbortSignal): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tree`,
+ { signal },
+ );
+ if (!res.ok) return [];
+ const data = await res.json();
+ return (data.entries as ApiTreeEntry[]) ?? [];
+}
+
+export async function fetchCommits(owner: string, name: string, signal?: AbortSignal): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits`,
+ { signal },
+ );
+ if (!res.ok) return [];
+ const data = await res.json();
+ return (data.commits as ApiCommit[]) ?? [];
+}
+
+export async function fetchBlob(
+ owner: string,
+ name: string,
+ path: string,
+ signal?: AbortSignal,
+): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/blob/${path}`,
+ { signal },
+ );
+ if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`);
+ return res.text();
+}
+
+export async function fetchSubtree(
+ owner: string,
+ name: string,
+ subpath: string,
+ signal?: AbortSignal,
+): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tree/${subpath}`,
+ { signal },
+ );
+ if (!res.ok) return [];
+ const data = await res.json();
+ return (data.entries as ApiTreeEntry[]) ?? [];
+}
+
+export async function fetchPulls(owner: string, name: string, signal?: AbortSignal): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`,
+ { signal },
+ );
+ if (!res.ok) return 0;
+ const data = await res.json();
+ return data.count ?? (data.pulls?.length ?? 0);
+}
+
+export async function fetchIssues(owner: string, name: string, signal?: AbortSignal): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues`,
+ { signal },
+ );
+ if (!res.ok) return 0;
+ const data = await res.json();
+ return data.count ?? (data.issues?.length ?? 0);
+}
+
+export async function fetchEvents(owner: string, name: string, signal?: AbortSignal): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/events`,
+ { signal },
+ );
+ if (!res.ok) return 0;
+ const data = await res.json();
+ return data.count ?? (data.events?.length ?? 0);
+}
+
+export async function fetchCerts(owner: string, name: string, signal?: AbortSignal): Promise {
+ const res = await fetch(
+ `${BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/certs`,
+ { signal },
+ );
+ if (!res.ok) return 0;
+ const data = await res.json();
+ return data.count ?? (data.certificates?.length ?? 0);
+}
+
+export function timeAgo(isoString: string): string {
+ const diff = Date.now() - new Date(isoString).getTime();
+ const s = Math.floor(diff / 1000);
+ if (s < 60) return `${s}s ago`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m ago`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h}h ago`;
+ const d = Math.floor(h / 24);
+ if (d < 30) return `${d}d ago`;
+ const mo = Math.floor(d / 30);
+ if (mo < 12) return `${mo}mo ago`;
+ return `${Math.floor(mo / 12)}y ago`;
+}
+
+export function formatDate(isoString: string): string {
+ return new Date(isoString).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+export function formatFileSize(bytes: number | null): string {
+ if (bytes === null) return '—';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+import type { Repository, RepoFile, RepoCommit } from '../types/repo';
+
+export function mapTreeEntriesToFiles(entries: ApiTreeEntry[]): RepoFile[] {
+ return entries.map(e => ({
+ name: e.name,
+ size: formatFileSize(e.size),
+ type: e.type === 'blob' ? 'file' : 'dir',
+ }));
+}
+
+export function mapApiRepo(
+ apiRepo: ApiRepo,
+ commits: ApiCommit[] = [],
+ treeEntries: ApiTreeEntry[] = [],
+): Repository {
+ const mappedCommits: RepoCommit[] = commits.map(c => ({
+ hash: c.hash,
+ shortHash: c.hash.substring(0, 7),
+ message: c.message,
+ time: timeAgo(c.date),
+ author: c.author,
+ }));
+
+ const files: RepoFile[] = treeEntries.map(e => ({
+ name: e.name,
+ size: formatFileSize(e.size),
+ type: e.type === 'blob' ? 'file' : 'dir',
+ }));
+
+ return {
+ id: apiRepo.id,
+ owner: apiRepo.owner_did,
+ name: apiRepo.name,
+ description: apiRepo.description || '',
+ branch: apiRepo.default_branch,
+ updatedAt: timeAgo(apiRepo.updated_at),
+ createdAt: formatDate(apiRepo.created_at),
+ stars: apiRepo.star_count,
+ visibility: apiRepo.is_public ? 'public' : 'private',
+ isMirror: apiRepo.forked_from !== null,
+ latestCommit: mappedCommits[0],
+ commits: mappedCommits,
+ files,
+ cloneUrl: apiRepo.clone_url,
+ };
+}
diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts
new file mode 100644
index 0000000..c5c2010
--- /dev/null
+++ b/src/lib/mock-data.ts
@@ -0,0 +1,2 @@
+export const PER_PAGE_OPTIONS = [10, 25, 50, 100];
+export const DEFAULT_PER_PAGE = 25;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..9ad0df4
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/src/pages/RepositoriesPage.tsx b/src/pages/RepositoriesPage.tsx
new file mode 100644
index 0000000..8e64ced
--- /dev/null
+++ b/src/pages/RepositoriesPage.tsx
@@ -0,0 +1,120 @@
+import { useState, useEffect } from 'react';
+import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
+import { DEFAULT_PER_PAGE } from '../lib/mock-data';
+import { useRepositories } from '../hooks/useRepositories';
+import { RepoTable } from '../components/repos/RepoTable';
+import { RepoPagination } from '../components/repos/RepoPagination';
+import { RepoHero } from '../components/repos/RepoHero';
+import { InputGroup, InputGroupInput, InputGroupAddon } from '../components/ui/input-group';
+
+function SearchIcon() {
+ return (
+
+ );
+}
+
+export default function RepositoriesPage() {
+ const [page, setPage] = useState(1);
+ const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE);
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const search = searchParams.get('q') ?? '';
+
+ const handleSearch = (val: string) => {
+ const dest = val ? `/repos?q=${encodeURIComponent(val)}` : '/repos';
+ navigate(dest, { replace: location.pathname === '/repos' });
+ };
+
+ useEffect(() => { setPage(1); }, [search]);
+
+ const { repos, totalCount, totalPages, windowStart, windowEnd, loading, error } = useRepositories({
+ page,
+ perPage,
+ search,
+ refreshKey,
+ });
+
+ const handlePerPageChange = (n: number) => {
+ setPerPage(n);
+ setPage(1);
+ };
+
+ const handleRefresh = () => {
+ if (loading) return;
+ setRefreshKey(k => k + 1);
+ };
+
+ return (
+
+
+
+
+
+
+ {/* Count line */}
+
+
+ {loading
+ ? 'Loading…'
+ : error
+ ? Error: {error}
+ : search
+ ? `${totalCount} ${totalCount === 1 ? 'result' : 'results'}`
+ : `${windowStart}–${windowEnd} of ${totalCount.toLocaleString()}`
+ }
+
+
+
+ {/* Search */}
+
+
+
+
+
+ handleSearch(e.target.value)}
+ placeholder="Search repositories…"
+ className="pl-11"
+ />
+
+
+
+ {/* Table */}
+
+
+ {/* Pagination */}
+ {!search && !loading && !error && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/RepositoryDetailPage.tsx b/src/pages/RepositoryDetailPage.tsx
new file mode 100644
index 0000000..62f1819
--- /dev/null
+++ b/src/pages/RepositoryDetailPage.tsx
@@ -0,0 +1,129 @@
+import { Link, useParams } from 'react-router-dom';
+import { useRepository } from '../hooks/useRepository';
+import { DetailHeader } from '../components/repo-detail/DetailHeader';
+import { StatsPanel } from '../components/repo-detail/StatsPanel';
+import { ClonePanel } from '../components/repo-detail/ClonePanel';
+import { CommitStrip } from '../components/repo-detail/CommitStrip';
+import { DetailTabs } from '../components/repo-detail/DetailTabs';
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from '../components/ui/breadcrumb';
+
+function PageBreadcrumb({ owner, name }: { owner: string; name: string }) {
+ return (
+
+
+
+
+
+ repos
+
+
+
+
+
+ {owner}
+
+
+
+ {name}
+
+
+
+ );
+}
+
+function NotFound({ owner, name }: { owner: string; name: string }) {
+ return (
+
+
+
+ ◈
+
+ Repository not found
+
+
+ {owner}/{name} doesn't exist on this node.
+
+
+ ← Back to repositories
+
+
+
+ );
+}
+
+function LoadingState({ owner, name }: { owner: string; name: string }) {
+ return (
+
+ );
+}
+
+function ErrorState({ owner, name, message }: { owner: string; name: string; message: string }) {
+ return (
+
+
+
+ ◈
+
+ Failed to load repository
+
+ {message}
+
+ ← Back to repositories
+
+
+
+ );
+}
+
+export default function RepositoryDetailPage() {
+ const { owner = '', name = '' } = useParams<{ owner: string; name: string }>();
+ const { repo, notFound, loading, error } = useRepository(owner, name);
+
+ if (loading) return ;
+ if (error) return ;
+ if (notFound || !repo) return ;
+
+ return (
+
+
+
+ {/* Two-column top section */}
+
+
+ {/* Commit strip */}
+ {repo.latestCommit && }
+
+ {/* Tabs + content */}
+
+
+ );
+}
diff --git a/src/types/repo.ts b/src/types/repo.ts
new file mode 100644
index 0000000..21f1e6c
--- /dev/null
+++ b/src/types/repo.ts
@@ -0,0 +1,30 @@
+export interface RepoFile {
+ name: string;
+ size: string;
+ type: 'file' | 'dir';
+}
+
+export interface RepoCommit {
+ hash: string;
+ shortHash: string;
+ message: string;
+ time: string;
+ author?: string;
+}
+
+export interface Repository {
+ id: string;
+ owner: string;
+ name: string;
+ description: string;
+ branch: string;
+ updatedAt: string;
+ createdAt: string;
+ stars: number;
+ visibility: 'public' | 'private';
+ isMirror: boolean;
+ latestCommit?: RepoCommit;
+ commits: RepoCommit[];
+ files: RepoFile[];
+ cloneUrl: string;
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..be9f99b
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "ignoreDeprecations": "6.0",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..d3c52ea
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "module": "esnext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..fbfe878
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,22 @@
+import path from 'path'
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ proxy: {
+ '/api': {
+ target: 'https://gitlawb-node-test.fly.dev',
+ changeOrigin: true,
+ secure: true,
+ },
+ },
+ },
+})
|