overhaul complete

This commit is contained in:
2026-05-07 11:02:34 +02:00
parent d860d78543
commit 779a1fdb82
29 changed files with 1612 additions and 213 deletions
+12 -6
View File
@@ -26,6 +26,9 @@ const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage'))
const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage'))
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
const CommitsPage = lazy(() => import('./pages/CommitsPage'))
const BranchesPage = lazy(() => import('./pages/BranchesPage'))
const StarredPage = lazy(() => import('./pages/StarredPage'))
const PRsPage = lazy(() => import('./pages/PRsPage'))
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
@@ -63,13 +66,16 @@ export default function App() {
<Route path="/" element={<AppShell />}>
<Route index element={<S><DashboardPage /></S>} />
<Route path="repos" element={<S><ReposPage /></S>} />
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
<Route path="repos/:owner/:repo/settings" element={<S><RepoSettingsPage /></S>} />
<Route path="repos/:owner/:repo/issues" element={<S><RepoIssuesPage /></S>} />
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="repos" element={<S><ReposPage /></S>} />
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
<Route path="repos/:owner/:repo/commits" element={<S><CommitsPage /></S>} />
<Route path="repos/:owner/:repo/branches" element={<S><BranchesPage /></S>} />
<Route path="repos/:owner/:repo/settings" element={<S><RepoSettingsPage /></S>} />
<Route path="repos/:owner/:repo/issues" element={<S><RepoIssuesPage /></S>} />
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="starred" element={<S><StarredPage /></S>} />
<Route path="pulls" element={<S><PRsPage /></S>} />
<Route path="pipelines" element={<S><PipelinesPage /></S>} />
<Route path="explore" element={<S><ExplorePage /></S>} />
+18 -12
View File
@@ -1,23 +1,29 @@
import { Outlet } from 'react-router-dom'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
import { BottomTabBar } from './BottomTabBar'
export function AppShell() {
return (
<div className="flex h-screen overflow-hidden bg-[#FAFBFC]">
{/* Desktop sidebar — hidden below md breakpoint */}
<Sidebar className="hidden md:flex" />
<div className="flex flex-col h-screen overflow-hidden bg-[#F4F5F7]">
{/* Top header — full width, always visible */}
<Header />
{/* Main content area — bottom padding leaves room for mobile tab bar */}
<main
id="main-content"
className="flex-1 overflow-y-auto pb-14 md:pb-0"
tabIndex={-1}
>
<Outlet />
</main>
<div className="flex flex-1 overflow-hidden">
{/* Desktop sidebar */}
<Sidebar className="hidden md:flex flex-col" />
{/* Mobile bottom tab bar — hidden above md breakpoint */}
{/* Main content */}
<main
id="main-content"
className="flex-1 overflow-y-auto pb-14 md:pb-0"
tabIndex={-1}
>
<Outlet />
</main>
</div>
{/* Mobile bottom tab bar */}
<BottomTabBar className="md:hidden" />
</div>
)
+147
View File
@@ -0,0 +1,147 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext'
import { useCreateRepo } from '../../api/queries/repos'
export function Header() {
const { user, isAuthenticated } = useAuth()
const [showCreate, setShowCreate] = useState(false)
const [search, setSearch] = useState('')
const navigate = useNavigate()
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (search.trim()) {
navigate(`/explore?q=${encodeURIComponent(search.trim())}`)
setSearch('')
}
}
return (
<header className="h-12 bg-[#1A2634] flex items-center gap-3 px-3 shrink-0 z-40 relative">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 shrink-0">
<div className="w-7 h-7 rounded bg-[#0052CC] flex items-center justify-center text-white font-bold text-sm">F</div>
<span className="text-white font-semibold text-sm hidden sm:block">ForgeBucket</span>
</Link>
{/* Search */}
<form onSubmit={handleSearch} className="flex-1 max-w-xl">
<div className="relative">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search repositories, issues, pull requests…"
className="w-full bg-white/10 text-white placeholder-white/40 text-xs rounded pl-8 pr-3 py-1.5 focus:outline-none focus:bg-white/15 focus:ring-1 focus:ring-white/30"
/>
</div>
</form>
<div className="flex items-center gap-1 ml-auto shrink-0">
{/* Create */}
{isAuthenticated && (
<div className="relative">
<button
onClick={() => setShowCreate(s => !s)}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-[#0052CC] hover:bg-[#0065FF] text-white text-xs font-semibold transition-colors"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Create
</button>
{showCreate && (
<CreateMenu onClose={() => setShowCreate(false)} />
)}
</div>
)}
{/* Notifications (placeholder) */}
<button className="w-8 h-8 rounded flex items-center justify-center text-white/60 hover:bg-white/10 hover:text-white transition-colors">
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
</button>
{/* Settings */}
<Link to="/settings" className="w-8 h-8 rounded flex items-center justify-center text-white/60 hover:bg-white/10 hover:text-white transition-colors">
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</Link>
{/* Avatar */}
{isAuthenticated ? (
<Link to="/profile" className="w-7 h-7 rounded-full bg-[#0052CC] flex items-center justify-center text-white text-xs font-bold hover:ring-2 hover:ring-white/30 transition-all">
{user?.username?.[0]?.toUpperCase()}
</Link>
) : (
<Link to="/login" className="px-3 py-1.5 rounded text-white/80 hover:text-white text-xs font-medium hover:bg-white/10 transition-colors">
Sign in
</Link>
)}
</div>
{/* Click-away for create menu */}
{showCreate && (
<div className="fixed inset-0 z-30" onClick={() => setShowCreate(false)} />
)}
</header>
)
}
function CreateMenu({ onClose }: { onClose: () => void }) {
const navigate = useNavigate()
const createRepo = useCreateRepo()
const [name, setName] = useState('')
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
const repo = await createRepo.mutateAsync({ name: name.trim() })
onClose()
navigate(`/repos/${repo.ownerName}/${repo.name}`)
}
return (
<div className="absolute right-0 top-full mt-1 w-72 bg-white rounded-lg shadow-xl border border-[#DFE1E6] z-50 overflow-hidden">
<div className="px-4 py-3 border-b border-[#DFE1E6]">
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide">Quick create</p>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-3">
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Repository name</label>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="my-new-repo"
autoFocus
required
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
/>
</div>
<button
type="submit"
disabled={createRepo.isPending || !name.trim()}
className="w-full py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50"
>
{createRepo.isPending ? 'Creating…' : 'Create repository'}
</button>
</form>
<div className="border-t border-[#DFE1E6]">
<Link to="/repos" onClick={onClose}
className="flex items-center gap-2 px-4 py-3 text-sm text-[#172B4D] hover:bg-[#F4F5F7]">
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
All repositories
</Link>
</div>
</div>
)
}
+171 -168
View File
@@ -1,192 +1,195 @@
import { useState } from 'react'
import { NavLink, Link } from 'react-router-dom'
import { NavLink, Link, useMatch } from 'react-router-dom'
import { cn } from '../../lib/utils'
import { useAuth } from '../../contexts/AuthContext'
type SidebarState = 'expanded' | 'collapsed' | 'hidden'
interface NavItem {
label: string
href: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{
label: 'Dashboard',
href: '/',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
),
},
{
label: 'Repositories',
href: '/repos',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
),
},
{
label: 'Pull Requests',
href: '/pulls',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
),
},
{
label: 'Pipelines',
href: '/pipelines',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
),
},
{
label: 'Explore',
href: '/explore',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
),
},
]
import { useRecentRepos } from '../../hooks/useRecentRepos'
import { useStarredRepos } from '../../hooks/useStarredRepos'
interface SidebarProps {
className?: string
}
export function Sidebar({ className }: SidebarProps) {
const [state, setState] = useState<SidebarState>('expanded')
const { user, isAuthenticated } = useAuth()
const { repos: recentRepos } = useRecentRepos()
const { toggle, isStarred } = useStarredRepos()
const [openRecent, setOpenRecent] = useState(true)
const isCollapsed = state === 'collapsed'
const width = isCollapsed ? 'w-14' : 'w-80'
// Detect if we're inside a repo
const repoMatch = useMatch('/repos/:owner/:repo/*')
const currentOwner = repoMatch?.params.owner
const currentRepo = repoMatch?.params.repo
return (
<aside
className={cn(
'relative flex flex-col h-full bg-[#172B4D] text-white transition-[width] duration-200 ease-in-out overflow-hidden shrink-0',
width,
className,
)}
aria-label="Main navigation"
>
{/* Logo + toggle */}
<div className="flex items-center h-14 px-3 border-b border-white/10 shrink-0">
{!isCollapsed && (
<span className="flex-1 text-sm font-semibold tracking-wide truncate">
ForgeBucket
</span>
)}
<button
onClick={() => setState(isCollapsed ? 'expanded' : 'collapsed')}
className="flex items-center justify-center w-8 h-8 rounded hover:bg-white/10 transition-colors"
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
className={cn('transition-transform duration-200', isCollapsed && 'rotate-180')}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
</div>
{/* Navigation */}
<nav className="flex-1 py-2 overflow-y-auto">
<ul className="flex flex-col gap-0.5 px-2">
{navItems.map((item) => (
<li key={item.href}>
<NavLink
to={item.href}
end={item.href === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded px-2 transition-colors',
'min-h-[44px]', // WCAG touch target
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white',
)
}
title={isCollapsed ? item.label : undefined}
>
<span className="shrink-0">{item.icon}</span>
{!isCollapsed && (
<span className="text-sm font-medium truncate">{item.label}</span>
)}
</NavLink>
</li>
))}
</ul>
</nav>
{/* Bottom: user + settings */}
<div className="py-2 px-2 border-t border-white/10 shrink-0 flex flex-col gap-0.5">
{isAuthenticated ? (
<NavLink
to="/profile"
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded px-2 min-h-[44px] transition-colors',
isActive ? 'bg-white/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white',
)
}
title={isCollapsed ? user?.username : undefined}
>
<div className="w-5 h-5 rounded-full bg-[#0052CC] flex items-center justify-center text-[10px] font-bold shrink-0">
<aside className={cn('w-60 bg-[#1A2634] flex flex-col h-full overflow-y-auto shrink-0', className)}>
{/* User workspace header */}
{isAuthenticated && (
<div className="px-3 py-3 border-b border-white/10">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-[#0052CC] flex items-center justify-center text-white text-[10px] font-bold shrink-0">
{user?.username?.[0]?.toUpperCase()}
</div>
{!isCollapsed && <span className="text-sm font-medium truncate">{user?.username}</span>}
</NavLink>
) : (
<Link
to="/login"
className="flex items-center gap-3 rounded px-2 min-h-[44px] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
title={isCollapsed ? 'Sign in' : undefined}
>
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" />
</svg>
{!isCollapsed && <span className="text-sm font-medium">Sign in</span>}
</Link>
<span className="text-white text-xs font-semibold truncate">{user?.username}</span>
</div>
</div>
)}
<nav className="flex-1 py-2">
{/* ── Global nav ─────────────────────────────────────────────── */}
<SidebarItem to="/" icon={<HomeIcon />} label="For you" end />
<SidebarItem to="/pulls" icon={<PRIcon />} label="Pull requests" />
<SidebarItem to="/repos" icon={<RepoIcon />} label="Repositories" />
<SidebarItem to="/explore" icon={<ExploreIcon />} label="Explore" />
<SidebarItem to="/starred" icon={<StarIcon />} label="Starred" />
{/* ── Recent repos ───────────────────────────────────────────── */}
{recentRepos.length > 0 && (
<div className="mt-3">
<button
onClick={() => setOpenRecent(o => !o)}
className="flex items-center justify-between w-full px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40 hover:text-white/70 transition-colors"
>
<span>Recent</span>
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
className={cn('transition-transform', openRecent ? '' : '-rotate-90')}>
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
{openRecent && recentRepos.map(r => (
<RecentRepoItem
key={`${r.ownerName}/${r.name}`}
ownerName={r.ownerName}
name={r.name}
isActive={r.ownerName === currentOwner && r.name === currentRepo}
isStarred={isStarred(r.ownerName, r.name)}
onStar={() => toggle(r.ownerName, r.name)}
/>
))}
</div>
)}
<NavLink
to="/settings"
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded px-2 min-h-[44px] transition-colors',
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white',
)
}
title={isCollapsed ? 'Settings' : undefined}
>
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
{/* ── Repo context sub-nav ────────────────────────────────────── */}
{currentOwner && currentRepo && (
<div className="mt-3 border-t border-white/10 pt-3">
<p className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40 truncate">
{currentOwner}/{currentRepo}
</p>
<RepoSubNav owner={currentOwner} repo={currentRepo} />
</div>
)}
</nav>
{/* ── Bottom: customize ─────────────────────────────────────────── */}
<div className="border-t border-white/10 py-2">
<Link to="/settings"
className="flex items-center gap-2 px-3 py-2 text-xs text-white/50 hover:text-white/80 hover:bg-white/5 transition-colors rounded mx-1">
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
{!isCollapsed && (
<span className="text-sm font-medium">Settings</span>
)}
</NavLink>
Settings
</Link>
</div>
</aside>
)
}
// ── Shared sub-components ────────────────────────────────────────────────────
function SidebarItem({ to, icon, label, end }: { to: string; icon: React.ReactNode; label: string; end?: boolean }) {
return (
<NavLink
to={to}
end={end}
className={({ isActive }) => cn(
'flex items-center gap-2.5 px-3 py-2 mx-1 rounded text-sm transition-colors min-h-[36px]',
isActive
? 'bg-white/12 text-white font-medium'
: 'text-white/65 hover:bg-white/8 hover:text-white',
)}
>
<span className="shrink-0 opacity-80">{icon}</span>
<span className="truncate">{label}</span>
</NavLink>
)
}
function RecentRepoItem({ ownerName, name, isActive, isStarred, onStar }: {
ownerName: string; name: string; isActive: boolean; isStarred: boolean; onStar: () => void
}) {
return (
<div className={cn(
'group flex items-center gap-2 mx-1 rounded transition-colors',
isActive ? 'bg-white/12' : 'hover:bg-white/8',
)}>
<Link to={`/repos/${ownerName}/${name}`}
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-1.5">
<div className="w-4 h-4 rounded-sm bg-[#0052CC]/70 flex items-center justify-center shrink-0">
<svg width="9" height="9" fill="white" viewBox="0 0 24 24">
<path d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776"/>
</svg>
</div>
<span className="text-xs text-white/75 truncate">{name}</span>
</Link>
<button onClick={onStar}
className={cn('mr-2 opacity-0 group-hover:opacity-100 transition-opacity', isStarred && 'opacity-100')}>
<svg width="12" height="12" fill={isStarred ? '#F79009' : 'none'} stroke={isStarred ? '#F79009' : 'rgba(255,255,255,0.5)'}
strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</button>
</div>
)
}
function RepoSubNav({ owner, repo }: { owner: string; repo: string }) {
const base = `/repos/${owner}/${repo}`
const items = [
{ label: 'Source', to: base, icon: <SourceIcon />, end: true },
{ label: 'Commits', to: `${base}/commits`, icon: <CommitsIcon /> },
{ label: 'Branches', to: `${base}/branches`, icon: <BranchIcon /> },
{ label: 'Pull requests', to: `${base}/pulls`, icon: <PRIcon /> },
{ label: 'Issues', to: `${base}/issues`, icon: <IssueIcon /> },
{ label: 'Pipelines', to: `${base}/pipelines`, icon: <PipelineIcon /> },
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
]
return (
<div>
{items.map(item => (
<NavLink key={item.to} to={item.to} end={item.end}
className={({ isActive }) => cn(
'flex items-center gap-2 px-3 py-1.5 mx-1 rounded text-xs transition-colors',
isActive
? 'bg-white/12 text-white'
: 'text-white/55 hover:bg-white/8 hover:text-white/90',
)}>
<span className="shrink-0">{item.icon}</span>
{item.label}
</NavLink>
))}
</div>
)
}
// ── Icons ────────────────────────────────────────────────────────────────────
const I = ({ d, filled }: { d: string | string[]; filled?: boolean }) => (
<svg width="16" height="16" fill={filled ? 'currentColor' : 'none'} stroke={filled ? 'none' : 'currentColor'}
strokeWidth="1.5" viewBox="0 0 24 24">
{(Array.isArray(d) ? d : [d]).map((path, i) => (
<path key={i} strokeLinecap="round" strokeLinejoin="round" d={path} />
))}
</svg>
)
const HomeIcon = () => <I d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
const PRIcon = () => <I d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
const RepoIcon = () => <I d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
const ExploreIcon = () => <I d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
const StarIcon = () => <I d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
const SourceIcon = () => <I d={['M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5']} />
const CommitsIcon = () => <I d={['M12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z', 'M12 21.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z', 'M12 3.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z']} />
const BranchIcon = () => <I d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v3.75A2.25 2.25 0 0 1 6 12H5.25A2.25 2.25 0 0 0 3 14.25v2.25A2.25 2.25 0 0 0 5.25 18.75H6a2.25 2.25 0 0 0 2.25-2.25V15m0 0a3 3 0 1 0 6 0 3 3 0 0 0-6 0Zm0 0h3" />
const IssueIcon = () => <I d={['M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z']} />
const PipelineIcon = () => <I d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
const SettingsSmIcon = () => <I d={['M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z']} />
+30
View File
@@ -0,0 +1,30 @@
import { useCallback } from 'react'
export interface RecentRepo {
ownerName: string
name: string
visitedAt: number
}
const KEY = 'fb_recent_repos'
const MAX = 5
function read(): RecentRepo[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? '[]')
} catch {
return []
}
}
export function useRecentRepos() {
const repos = read()
const track = useCallback((ownerName: string, name: string) => {
const existing = read().filter(r => !(r.ownerName === ownerName && r.name === name))
const updated: RecentRepo[] = [{ ownerName, name, visitedAt: Date.now() }, ...existing].slice(0, MAX)
localStorage.setItem(KEY, JSON.stringify(updated))
}, [])
return { repos, track }
}
+34
View File
@@ -0,0 +1,34 @@
import { useState, useCallback } from 'react'
const KEY = 'fb_starred_repos'
function read(): string[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? '[]')
} catch {
return []
}
}
function repoKey(ownerName: string, name: string) {
return `${ownerName}/${name}`
}
export function useStarredRepos() {
const [starred, setStarred] = useState<string[]>(read)
const toggle = useCallback((ownerName: string, name: string) => {
const key = repoKey(ownerName, name)
setStarred(prev => {
const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
localStorage.setItem(KEY, JSON.stringify(next))
return next
})
}, [])
const isStarred = useCallback((ownerName: string, name: string) => {
return starred.includes(repoKey(ownerName, name))
}, [starred])
return { starred, toggle, isStarred }
}
+56
View File
@@ -0,0 +1,56 @@
import { useParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../api/client'
import { useRecentRepos } from '../hooks/useRecentRepos'
import { useEffect } from 'react'
const branchSchema = z.object({ name: z.string() })
export default function BranchesPage() {
const { owner = '', repo = '' } = useParams<{ owner: string; repo: string }>()
const { track } = useRecentRepos()
useEffect(() => { if (owner && repo) track(owner, repo) }, [owner, repo])
const { data: branches, isLoading, isError } = useQuery({
queryKey: ['repos', owner, repo, 'branches'],
queryFn: () => api.get(`/api/v1/repos/${owner}/${repo}/branches`, z.array(branchSchema)),
enabled: Boolean(owner && repo),
})
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
<div className="flex items-center gap-1 text-sm mb-4">
<Link to={`/repos/${owner}/${repo}`} className="text-[#0052CC] hover:underline">{repo}</Link>
<span className="text-[#5E6C84]">/</span>
<span className="font-semibold text-[#172B4D]">Branches</span>
</div>
<h1 className="text-xl font-semibold text-[#172B4D] mb-4">Branches</h1>
{isLoading && <p className="text-sm text-[#5E6C84]">Loading branches</p>}
{isError && <p className="text-sm text-[#DE350B]">Failed to load branches.</p>}
{!isLoading && !branches?.length && (
<p className="text-sm text-[#5E6C84] py-8 text-center">No branches yet.</p>
)}
{branches && branches.length > 0 && (
<div className="border border-[#DFE1E6] rounded overflow-hidden bg-white">
{branches.map((branch, i) => (
<div key={branch.name}
className={`flex items-center gap-3 px-4 py-3 ${i > 0 ? 'border-t border-[#DFE1E6]' : ''} hover:bg-[#FAFBFC]`}>
<svg width="14" height="14" fill="none" stroke="#5E6C84" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v3.75A2.25 2.25 0 0 1 6 12H5.25A2.25 2.25 0 0 0 3 14.25v2.25A2.25 2.25 0 0 0 5.25 18.75H6a2.25 2.25 0 0 0 2.25-2.25V15m0 0a3 3 0 1 0 6 0 3 3 0 0 0-6 0Zm0 0h3" />
</svg>
<Link to={`/repos/${owner}/${repo}?ref=${branch.name}`}
className="text-sm text-[#0052CC] hover:underline font-mono">
{branch.name}
</Link>
</div>
))}
</div>
)}
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../api/client'
import { useRecentRepos } from '../hooks/useRecentRepos'
import { useEffect } from 'react'
const commitSchema = z.object({
hash: z.string(),
author: z.string(),
message: z.string(),
date: z.string(),
})
export default function CommitsPage() {
const { owner = '', repo = '' } = useParams<{ owner: string; repo: string }>()
const { track } = useRecentRepos()
useEffect(() => { if (owner && repo) track(owner, repo) }, [owner, repo])
const { data: commits, isLoading, isError } = useQuery({
queryKey: ['repos', owner, repo, 'commits'],
queryFn: () => api.get(`/api/v1/repos/${owner}/${repo}/commits?limit=50`, z.array(commitSchema)),
enabled: Boolean(owner && repo),
})
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
<div className="flex items-center gap-1 text-sm mb-4">
<Link to={`/repos/${owner}/${repo}`} className="text-[#0052CC] hover:underline">{repo}</Link>
<span className="text-[#5E6C84]">/</span>
<span className="font-semibold text-[#172B4D]">Commits</span>
</div>
{isLoading && <p className="text-sm text-[#5E6C84]">Loading commits</p>}
{isError && <p className="text-sm text-[#DE350B]">Failed to load commits.</p>}
{!isLoading && !commits?.length && (
<p className="text-sm text-[#5E6C84] py-8 text-center">No commits yet. Push your first commit to get started.</p>
)}
{commits && commits.length > 0 && (
<div className="border border-[#DFE1E6] rounded overflow-hidden bg-white">
{commits.map((commit, i) => (
<div key={commit.hash}
className={`flex items-start gap-4 px-4 py-3 ${i > 0 ? 'border-t border-[#DFE1E6]' : ''} hover:bg-[#FAFBFC]`}>
<div className="w-7 h-7 rounded-full bg-[#0052CC]/10 text-[#0052CC] flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
{commit.author?.[0]?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#172B4D] truncate">{commit.message}</p>
<p className="text-xs text-[#5E6C84] mt-0.5">
{commit.author} · {new Date(commit.date).toLocaleDateString()}
</p>
</div>
<code className="text-xs font-mono text-[#5E6C84] bg-[#F4F5F7] px-2 py-0.5 rounded shrink-0">
{commit.hash.slice(0, 7)}
</code>
</div>
))}
</div>
)}
</div>
)
}
+104 -27
View File
@@ -1,56 +1,133 @@
import { Link } from 'react-router-dom'
import { useRepos } from '../api/queries/repos'
import { usePRs } from '../api/queries/prs'
import { useAuth } from '../contexts/AuthContext'
import { RepoCard } from '../components/repos/RepoCard'
import { RepoListSkeleton } from '../ui/Skeleton'
import { RepoListSkeleton, PRListSkeleton } from '../ui/Skeleton'
export default function DashboardPage() {
const { data: repos, isLoading } = useRepos()
const { user, isAuthenticated } = useAuth()
const { data: repos, isLoading: reposLoading } = useRepos()
const hasRepos = repos && repos.length > 0
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-8">
<div>
<h1 className="text-xl font-semibold text-[#172B4D]">Dashboard</h1>
<p className="text-sm text-[#5E6C84] mt-1">Your repositories and recent activity.</p>
</div>
{/* Hero — only when no repos yet */}
{!reposLoading && !hasRepos && isAuthenticated && (
<div className="rounded-lg border border-[#DFE1E6] bg-white overflow-hidden">
<div className="flex items-center gap-8 p-8">
<HeroIllustration />
<div>
<h1 className="text-xl font-semibold text-[#172B4D]">
Welcome to ForgeBucket{user?.username ? `, ${user.username}` : ''}!
</h1>
<p className="text-sm text-[#5E6C84] mt-2 max-w-md">
Get started by creating your first repository, pushing code, and collaborating through pull requests.
</p>
<div className="flex items-center gap-3 mt-5">
<Link to="/repos"
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[36px] flex items-center">
Create repository
</Link>
<Link to="/explore"
className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] font-medium hover:bg-[#F4F5F7] min-h-[36px] flex items-center">
Explore
</Link>
</div>
</div>
</div>
</div>
)}
{/* Recent repositories */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[#172B4D] uppercase tracking-wide">
Your Repositories
<h2 className="text-sm font-semibold text-[#172B4D] flex items-center gap-2">
Recent repositories
<Link to="/repos"
className="ml-1 w-5 h-5 rounded border border-[#DFE1E6] text-[#5E6C84] flex items-center justify-center hover:bg-[#F4F5F7] text-xs">
+
</Link>
</h2>
<Link to="/repos" className="text-xs text-[#0052CC] hover:underline">View all</Link>
</div>
{isLoading ? (
{reposLoading ? (
<RepoListSkeleton />
) : !repos?.length ? (
<EmptyRepos />
<div className="border border-dashed border-[#DFE1E6] rounded p-6 text-center">
<p className="text-sm text-[#5E6C84]">No repositories yet.</p>
<Link to="/repos" className="text-xs text-[#0052CC] hover:underline mt-1 inline-block">
Create your first repository
</Link>
</div>
) : (
<div className="flex flex-col gap-2">
{repos.slice(0, 5).map(r => <RepoCard key={r.id} repo={r} />)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{repos.slice(0, 6).map(r => <RepoCard key={r.id} repo={r} />)}
</div>
)}
</section>
{/* Open pull requests — across all repos */}
{repos && repos.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[#172B4D]">Pull requests</h2>
</div>
<PullRequestSummary repos={repos.map(r => ({ owner: r.ownerName, name: r.name }))} />
</section>
)}
</div>
)
}
function EmptyRepos() {
return (
<div className="flex flex-col items-center justify-center py-12 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
<div>
<p className="text-sm font-medium text-[#172B4D]">No repositories yet</p>
<p className="text-xs text-[#5E6C84] mt-1">Create your first repository to get started.</p>
function PullRequestSummary({ repos }: { repos: { owner: string; name: string }[] }) {
const first = repos[0]
const { data: prs, isLoading } = usePRs(first?.owner ?? '', first?.name ?? '')
if (isLoading) return <PRListSkeleton />
const open = prs?.filter(p => p.status === 'open') ?? []
if (!open.length) {
return (
<div className="border border-[#DFE1E6] rounded p-6 text-center bg-white">
<p className="text-sm text-[#5E6C84]">You have no open pull requests.</p>
</div>
<Link
to="/repos"
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[44px] flex items-center"
>
New repository
</Link>
)
}
return (
<div className="flex flex-col gap-2">
{open.slice(0, 5).map(pr => (
<Link
key={pr.id}
to={`/repos/${first.owner}/${first.name}/pulls/${pr.id}`}
className="flex items-center gap-3 p-4 border border-[#DFE1E6] rounded bg-white hover:border-[#4C9AFF] hover:bg-[#FAFBFC] transition-colors"
>
<svg width="16" height="16" fill="none" stroke="#00875A" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#172B4D] truncate">{pr.title}</p>
<p className="text-xs text-[#5E6C84] mt-0.5">
{first.name} · {pr.sourceBranch} {pr.targetBranch}
</p>
</div>
</Link>
))}
</div>
)
}
function HeroIllustration() {
return (
<div className="shrink-0 w-32 h-32 bg-[#DEEBFF] rounded-lg flex items-center justify-center text-[#0052CC]">
<svg width="64" height="64" fill="none" stroke="currentColor" strokeWidth="1" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
</div>
)
}
+4
View File
@@ -1,7 +1,9 @@
import { useEffect } from 'react'
import { useParams, useSearchParams, Link } from 'react-router-dom'
import { useRepo } from '../api/queries/repos'
import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoListSkeleton } from '../ui/Skeleton'
import { useRecentRepos } from '../hooks/useRecentRepos'
export default function RepoPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
@@ -10,6 +12,8 @@ export default function RepoPage() {
const ref = searchParams.get('ref') ?? ''
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
const { track } = useRecentRepos()
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
if (isError || !repo) return <div className="p-6 text-sm text-[#DE350B]">Repository not found.</div>
+44
View File
@@ -0,0 +1,44 @@
import { Link } from 'react-router-dom'
import { useStarredRepos } from '../hooks/useStarredRepos'
import { useRepos } from '../api/queries/repos'
export default function StarredPage() {
const { starred } = useStarredRepos()
const { data: repos } = useRepos()
const starredRepos = repos?.filter(r => starred.includes(`${r.ownerName}/${r.name}`)) ?? []
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
<h1 className="text-xl font-semibold text-[#172B4D] mb-6">Starred repositories</h1>
{!starredRepos.length ? (
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
<svg width="36" height="36" fill="none" stroke="#97A0AF" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
<div>
<p className="text-sm font-medium text-[#172B4D]">No starred repositories</p>
<p className="text-xs text-[#5E6C84] mt-1">Star repositories in the sidebar to find them here quickly.</p>
</div>
<Link to="/repos" className="text-xs text-[#0052CC] hover:underline">Browse repositories</Link>
</div>
) : (
<div className="flex flex-col gap-2">
{starredRepos.map(r => (
<Link key={r.id} to={`/repos/${r.ownerName}/${r.name}`}
className="flex items-center gap-3 p-4 border border-[#DFE1E6] rounded bg-white hover:border-[#4C9AFF] hover:bg-[#FAFBFC] transition-colors">
<svg width="16" height="16" fill="#F79009" viewBox="0 0 24 24">
<path d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"/>
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[#0052CC]">{r.ownerName}/{r.name}</p>
{r.description && <p className="text-xs text-[#5E6C84] truncate mt-0.5">{r.description}</p>}
</div>
</Link>
))}
</div>
)}
</div>
)
}