196 lines
13 KiB
TypeScript
196 lines
13 KiB
TypeScript
import { useState } from 'react'
|
|
import { NavLink, Link, useMatch } from 'react-router-dom'
|
|
import { cn } from '../../lib/utils'
|
|
import { useAuth } from '../../contexts/AuthContext'
|
|
import { useRecentRepos } from '../../hooks/useRecentRepos'
|
|
import { useStarredRepos } from '../../hooks/useStarredRepos'
|
|
|
|
interface SidebarProps {
|
|
className?: string
|
|
}
|
|
|
|
export function Sidebar({ className }: SidebarProps) {
|
|
const { user, isAuthenticated } = useAuth()
|
|
const { repos: recentRepos } = useRecentRepos()
|
|
const { toggle, isStarred } = useStarredRepos()
|
|
const [openRecent, setOpenRecent] = useState(true)
|
|
|
|
// 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('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>
|
|
<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>
|
|
)}
|
|
|
|
{/* ── 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>
|
|
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']} />
|