|
|
|
@@ -1,133 +1,581 @@
|
|
|
|
|
import { Link } from 'react-router-dom'
|
|
|
|
|
import { useRepos } from '../api/queries/repos'
|
|
|
|
|
import { usePRs } from '../api/queries/prs'
|
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
|
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
|
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
|
|
|
import { RepoCard } from '../components/repos/RepoCard'
|
|
|
|
|
import { RepoListSkeleton, PRListSkeleton } from '../ui/Skeleton'
|
|
|
|
|
import { useDashboard } from '../api/queries/dashboard'
|
|
|
|
|
import { useRepos } from '../api/queries/repos'
|
|
|
|
|
import { useRecentRepos } from '../hooks/useRecentRepos'
|
|
|
|
|
import { Skeleton } from '../ui/Skeleton'
|
|
|
|
|
import { RepoAvatar } from '../ui/RepoAvatar'
|
|
|
|
|
import type { DashPR, DashIssue, DashRepo } from '../api/queries/dashboard'
|
|
|
|
|
|
|
|
|
|
export default function DashboardPage() {
|
|
|
|
|
const { user, isAuthenticated } = useAuth()
|
|
|
|
|
const { data: repos, isLoading: reposLoading } = useRepos()
|
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const hasRepos = repos && repos.length > 0
|
|
|
|
|
function timeAgo(iso: string): string {
|
|
|
|
|
const diff = Date.now() - new Date(iso).getTime()
|
|
|
|
|
const min = Math.floor(diff / 60_000)
|
|
|
|
|
if (min < 1) return 'just now'
|
|
|
|
|
if (min < 60) return `${min}m ago`
|
|
|
|
|
const hr = Math.floor(min / 60)
|
|
|
|
|
if (hr < 24) return `${hr}h ago`
|
|
|
|
|
const d = Math.floor(hr / 24)
|
|
|
|
|
if (d < 30) return `${d}d ago`
|
|
|
|
|
return `${Math.floor(d / 30)}mo ago`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function greeting(username?: string): string {
|
|
|
|
|
const h = new Date().getHours()
|
|
|
|
|
const time = h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening'
|
|
|
|
|
return username ? `${time}, ${username}` : time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Command palette ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface CmdResult {
|
|
|
|
|
type: 'repo' | 'pr' | 'issue'
|
|
|
|
|
label: string
|
|
|
|
|
sub: string
|
|
|
|
|
href: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CommandPalette() {
|
|
|
|
|
const { data: dash } = useDashboard()
|
|
|
|
|
const { data: repos = [] } = useRepos()
|
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
|
const [q, setQ] = useState('')
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (e: KeyboardEvent) => {
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
setOpen(true)
|
|
|
|
|
setQ('')
|
|
|
|
|
setTimeout(() => inputRef.current?.focus(), 10)
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'Escape') setOpen(false)
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('keydown', handler)
|
|
|
|
|
return () => window.removeEventListener('keydown', handler)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const results: CmdResult[] = q.trim()
|
|
|
|
|
? [
|
|
|
|
|
...repos
|
|
|
|
|
.filter(r => r.name.toLowerCase().includes(q.toLowerCase()) || r.description?.toLowerCase().includes(q.toLowerCase()))
|
|
|
|
|
.slice(0, 4)
|
|
|
|
|
.map(r => ({ type: 'repo' as const, label: `${r.ownerName}/${r.name}`, sub: r.description || 'Repository', href: `/repos/${r.ownerName}/${r.name}` })),
|
|
|
|
|
...(dash?.myOpenPRs ?? [])
|
|
|
|
|
.filter(p => p.title.toLowerCase().includes(q.toLowerCase()))
|
|
|
|
|
.slice(0, 3)
|
|
|
|
|
.map(p => ({ type: 'pr' as const, label: p.title, sub: `${p.ownerName}/${p.repoName} · #${p.id}`, href: `/repos/${p.ownerName}/${p.repoName}/pulls/${p.id}` })),
|
|
|
|
|
...(dash?.myOpenIssues ?? [])
|
|
|
|
|
.filter(i => i.title.toLowerCase().includes(q.toLowerCase()))
|
|
|
|
|
.slice(0, 3)
|
|
|
|
|
.map(i => ({ type: 'issue' as const, label: i.title, sub: `${i.ownerName}/${i.repoName} · #${i.number}`, href: `/repos/${i.ownerName}/${i.repoName}/issues` })),
|
|
|
|
|
]
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
const [sel, setSel] = useState(0)
|
|
|
|
|
useEffect(() => setSel(0), [q])
|
|
|
|
|
|
|
|
|
|
function onKey(e: React.KeyboardEvent) {
|
|
|
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)) }
|
|
|
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(s - 1, 0)) }
|
|
|
|
|
if (e.key === 'Enter' && results[sel]) { navigate(results[sel].href); setOpen(false) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const typeIcon = (t: CmdResult['type']) => {
|
|
|
|
|
if (t === 'repo') return (
|
|
|
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25A2.25 2.25 0 0 0 4.5 16.5h15a2.25 2.25 0 0 0 2.25-2.25V9A2.25 2.25 0 0 0 19.5 6.75h-1.06l-2.44-2.44a1.5 1.5 0 0 0-1.061-.44H8.69Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
)
|
|
|
|
|
if (t === 'pr') return (
|
|
|
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
return (
|
|
|
|
|
<button onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 10) }}
|
|
|
|
|
className="w-full flex items-center gap-2.5 px-3 py-2.5 border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] text-[var(--c-muted)] text-sm hover:border-[var(--c-brand-focus)] hover:bg-[var(--c-surface-muted)] transition-colors group">
|
|
|
|
|
<svg 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>
|
|
|
|
|
<span className="flex-1 text-left">Search repos, PRs, issues…</span>
|
|
|
|
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono border border-[var(--c-border)] rounded text-[var(--c-subtle)] group-hover:border-[var(--c-brand-focus)]">
|
|
|
|
|
<span>⌘</span><span>K</span>
|
|
|
|
|
</kbd>
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-8">
|
|
|
|
|
|
|
|
|
|
{/* Hero — only when no repos yet */}
|
|
|
|
|
{!reposLoading && !hasRepos && isAuthenticated && (
|
|
|
|
|
<div className="rounded-lg border border-[var(--c-border)] bg-[var(--c-surface)] overflow-hidden">
|
|
|
|
|
<div className="flex items-center gap-8 p-8">
|
|
|
|
|
<HeroIllustration />
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">
|
|
|
|
|
Welcome to ForgeBucket{user?.username ? `, ${user.username}` : ''}!
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-sm text-[var(--c-muted)] 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-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] min-h-[36px] flex items-center">
|
|
|
|
|
Create repository
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/40 backdrop-blur-sm" onClick={() => setOpen(false)}>
|
|
|
|
|
<div className="w-full max-w-xl mx-4 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-xl shadow-2xl overflow-hidden" onClick={e => e.stopPropagation()}>
|
|
|
|
|
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-[var(--c-border)]">
|
|
|
|
|
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" 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 ref={inputRef} value={q} onChange={e => setQ(e.target.value)} onKeyDown={onKey}
|
|
|
|
|
placeholder="Search repos, PRs, issues…"
|
|
|
|
|
className="flex-1 bg-transparent text-sm text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none" />
|
|
|
|
|
<kbd className="text-[10px] font-mono text-[var(--c-subtle)] border border-[var(--c-border)] rounded px-1.5 py-0.5">esc</kbd>
|
|
|
|
|
</div>
|
|
|
|
|
{results.length > 0 ? (
|
|
|
|
|
<ul className="max-h-72 overflow-y-auto py-1">
|
|
|
|
|
{results.map((res, i) => (
|
|
|
|
|
<li key={res.href}>
|
|
|
|
|
<Link to={res.href} onClick={() => setOpen(false)}
|
|
|
|
|
className={`flex items-center gap-3 px-4 py-2.5 transition-colors ${i === sel ? 'bg-[var(--c-brand-tint)] text-[var(--c-brand)]' : 'text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]'}`}>
|
|
|
|
|
<span className={i === sel ? 'text-[var(--c-brand)]' : 'text-[var(--c-muted)]'}>{typeIcon(res.type)}</span>
|
|
|
|
|
<span className="flex-1 min-w-0">
|
|
|
|
|
<span className="text-sm font-medium truncate block">{res.label}</span>
|
|
|
|
|
<span className="text-xs text-[var(--c-muted)] truncate block">{res.sub}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</Link>
|
|
|
|
|
<Link to="/explore"
|
|
|
|
|
className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] font-medium hover:bg-[var(--c-surface-muted)] 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-[var(--c-text)] flex items-center gap-2">
|
|
|
|
|
Recent repositories
|
|
|
|
|
<Link to="/repos"
|
|
|
|
|
className="ml-1 w-5 h-5 rounded border border-[var(--c-border)] text-[var(--c-muted)] flex items-center justify-center hover:bg-[var(--c-surface-muted)] text-xs">
|
|
|
|
|
+
|
|
|
|
|
</Link>
|
|
|
|
|
</h2>
|
|
|
|
|
<Link to="/repos" className="text-xs text-[var(--c-brand)] hover:underline">View all</Link>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{reposLoading ? (
|
|
|
|
|
<RepoListSkeleton />
|
|
|
|
|
) : !repos?.length ? (
|
|
|
|
|
<div className="border border-dashed border-[var(--c-border)] rounded p-6 text-center">
|
|
|
|
|
<p className="text-sm text-[var(--c-muted)]">No repositories yet.</p>
|
|
|
|
|
<Link to="/repos" className="text-xs text-[var(--c-brand)] hover:underline mt-1 inline-block">
|
|
|
|
|
Create your first repository →
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
) : q ? (
|
|
|
|
|
<div className="px-4 py-6 text-center text-sm text-[var(--c-muted)]">No results for "{q}"</div>
|
|
|
|
|
) : (
|
|
|
|
|
<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 className="px-4 py-3">
|
|
|
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-2">Quick navigate</p>
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{[
|
|
|
|
|
{ label: 'My repos', href: '/repos' },
|
|
|
|
|
{ label: 'Open PRs', href: '/pulls' },
|
|
|
|
|
{ label: 'Issues', href: '/issues' },
|
|
|
|
|
{ label: 'Explore', href: '/explore' },
|
|
|
|
|
{ label: 'Settings', href: '/settings' },
|
|
|
|
|
].map(link => (
|
|
|
|
|
<Link key={link.href} to={link.href} onClick={() => setOpen(false)}
|
|
|
|
|
className="px-2.5 py-1 text-xs rounded-md bg-[var(--c-surface-muted)] border border-[var(--c-border)] text-[var(--c-text)] hover:border-[var(--c-brand-focus)] hover:text-[var(--c-brand)] transition-colors">
|
|
|
|
|
{link.label}
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
<div className="px-4 py-2 border-t border-[var(--c-border)] flex items-center gap-4 text-[10px] text-[var(--c-subtle)]">
|
|
|
|
|
<span className="flex items-center gap-1"><kbd className="font-mono">↑↓</kbd> navigate</span>
|
|
|
|
|
<span className="flex items-center gap-1"><kbd className="font-mono">↵</kbd> open</span>
|
|
|
|
|
<span className="flex items-center gap-1"><kbd className="font-mono">esc</kbd> close</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
{/* Open pull requests — across all repos */}
|
|
|
|
|
{repos && repos.length > 0 && (
|
|
|
|
|
// ── Stat pill ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function StatPill({ label, value, to, warn }: { label: string; value: number; to: string; warn?: boolean }) {
|
|
|
|
|
return (
|
|
|
|
|
<Link to={to} className="flex flex-col items-center px-4 py-2.5 rounded-lg border border-[var(--c-border)] bg-[var(--c-surface)] hover:border-[var(--c-brand-focus)] hover:bg-[var(--c-surface-muted)] transition-colors group min-w-[72px]">
|
|
|
|
|
<span className={`text-xl font-bold tabular-nums ${warn && value > 0 ? 'text-[var(--c-warning)]' : 'text-[var(--c-text)]'} group-hover:text-[var(--c-brand)] transition-colors`}>{value}</span>
|
|
|
|
|
<span className="text-[10px] text-[var(--c-muted)] mt-0.5 whitespace-nowrap">{label}</span>
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Section wrapper ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function Section({ title, count, icon, children, action }: {
|
|
|
|
|
title: string; count?: number; icon: React.ReactNode; children: React.ReactNode; action?: React.ReactNode
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<section>
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Pull requests</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<PullRequestSummary repos={repos.map(r => ({ owner: r.ownerName, name: r.name }))} />
|
|
|
|
|
</section>
|
|
|
|
|
<div className="flex items-center justify-between mb-2.5">
|
|
|
|
|
<h2 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)]">
|
|
|
|
|
<span className="text-[var(--c-muted)]">{icon}</span>
|
|
|
|
|
{title}
|
|
|
|
|
{count !== undefined && count > 0 && (
|
|
|
|
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded-full bg-[var(--c-brand)] text-white leading-none">{count}</span>
|
|
|
|
|
)}
|
|
|
|
|
</h2>
|
|
|
|
|
{action}
|
|
|
|
|
</div>
|
|
|
|
|
{children}
|
|
|
|
|
</section>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PullRequestSummary({ repos }: { repos: { owner: string; name: string }[] }) {
|
|
|
|
|
const first = repos[0]
|
|
|
|
|
const { data: prs, isLoading } = usePRs(first?.owner ?? '', first?.name ?? '')
|
|
|
|
|
// ── PR row ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
if (isLoading) return <PRListSkeleton />
|
|
|
|
|
|
|
|
|
|
const open = prs?.filter(p => p.status === 'open') ?? []
|
|
|
|
|
|
|
|
|
|
if (!open.length) {
|
|
|
|
|
function PRRow({ pr, badge }: { pr: DashPR; badge?: React.ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="border border-[var(--c-border)] rounded p-6 text-center bg-[var(--c-surface)]">
|
|
|
|
|
<p className="text-sm text-[var(--c-muted)]">You have no open pull requests.</p>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-[var(--c-border)] rounded bg-[var(--c-surface)] hover:border-[var(--c-brand-focus)] hover:bg-[var(--c-surface-raised)] transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<svg width="16" height="16" fill="none" stroke="var(--c-success)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
|
|
|
|
|
<Link to={`/repos/${pr.ownerName}/${pr.repoName}/pulls/${pr.id}`}
|
|
|
|
|
className="flex items-start gap-3 px-3.5 py-2.5 hover:bg-[var(--c-surface-muted)] transition-colors group">
|
|
|
|
|
<svg className="mt-0.5 shrink-0 text-[var(--c-success)]" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
|
|
|
<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-[var(--c-text)] truncate">{pr.title}</p>
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] truncate transition-colors">{pr.title}</span>
|
|
|
|
|
{badge}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
|
|
|
{first.name} · {pr.sourceBranch} → {pr.targetBranch}
|
|
|
|
|
<span className="font-mono">{pr.ownerName}/{pr.repoName}</span>
|
|
|
|
|
{' · '}
|
|
|
|
|
<span className="font-mono text-[var(--c-muted)]">{pr.sourceBranch}</span>
|
|
|
|
|
{' → '}
|
|
|
|
|
<span className="font-mono text-[var(--c-muted)]">{pr.targetBranch}</span>
|
|
|
|
|
{' · '}
|
|
|
|
|
{timeAgo(pr.updatedAt)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Issue row ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function IssueRow({ issue }: { issue: DashIssue }) {
|
|
|
|
|
return (
|
|
|
|
|
<Link to={`/repos/${issue.ownerName}/${issue.repoName}/issues`}
|
|
|
|
|
className="flex items-start gap-3 px-3.5 py-2.5 hover:bg-[var(--c-surface-muted)] transition-colors group">
|
|
|
|
|
<svg className="mt-0.5 shrink-0 text-[var(--c-brand)]" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] truncate block transition-colors">{issue.title}</span>
|
|
|
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
|
|
|
<span className="font-mono">{issue.ownerName}/{issue.repoName}</span>
|
|
|
|
|
{' · '}#{issue.number}
|
|
|
|
|
{' · '}
|
|
|
|
|
{timeAgo(issue.updatedAt)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Repo workspace card ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function RepoCard({ repo }: { repo: DashRepo }) {
|
|
|
|
|
return (
|
|
|
|
|
<Link to={`/repos/${repo.ownerName}/${repo.name}`}
|
|
|
|
|
className="flex items-center gap-3 px-3.5 py-2.5 hover:bg-[var(--c-surface-muted)] transition-colors group">
|
|
|
|
|
<RepoAvatar ownerName={repo.ownerName} name={repo.name} avatarUrl={repo.avatarUrl} size={28} />
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] truncate transition-colors">{repo.name}</span>
|
|
|
|
|
{repo.isPrivate && (
|
|
|
|
|
<span className="text-[9px] font-semibold uppercase tracking-wider border border-[var(--c-border)] text-[var(--c-muted)] px-1 py-px rounded shrink-0">private</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2.5 mt-0.5">
|
|
|
|
|
{repo.openPrCount > 0 && (
|
|
|
|
|
<span className="flex items-center gap-0.5 text-[10px] text-[var(--c-muted)]">
|
|
|
|
|
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
|
|
|
<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>
|
|
|
|
|
{repo.openPrCount} PR{repo.openPrCount !== 1 ? 's' : ''}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{repo.openIssueCount > 0 && (
|
|
|
|
|
<span className="flex items-center gap-0.5 text-[10px] text-[var(--c-muted)]">
|
|
|
|
|
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
{repo.openIssueCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-[10px] text-[var(--c-subtle)]">{timeAgo(repo.updatedAt)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<svg className="shrink-0 text-[var(--c-border)] group-hover:text-[var(--c-brand)] transition-colors" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
|
|
|
</svg>
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Empty state ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function Empty({ message, action }: { message: string; action?: React.ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="px-3.5 py-5 text-center">
|
|
|
|
|
<p className="text-xs text-[var(--c-muted)]">{message}</p>
|
|
|
|
|
{action && <div className="mt-2">{action}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Panel wrapper ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function Panel({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={`border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden divide-y divide-[var(--c-border)] ${className}`}>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Skeleton states ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function PanelSkeleton({ rows = 3 }: { rows?: number }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] divide-y divide-[var(--c-border)]">
|
|
|
|
|
{Array.from({ length: rows }).map((_, i) => (
|
|
|
|
|
<div key={i} className="flex items-center gap-3 px-3.5 py-2.5">
|
|
|
|
|
<Skeleton className="w-3.5 h-3.5 rounded-full shrink-0" />
|
|
|
|
|
<div className="flex-1 space-y-1.5">
|
|
|
|
|
<Skeleton className="h-3.5 w-3/4 rounded" />
|
|
|
|
|
<Skeleton className="h-2.5 w-1/2 rounded" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function HeroIllustration() {
|
|
|
|
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export default function DashboardPage() {
|
|
|
|
|
const { user } = useAuth()
|
|
|
|
|
const { data: dash, isLoading } = useDashboard()
|
|
|
|
|
const { repos: recentRepos } = useRecentRepos()
|
|
|
|
|
|
|
|
|
|
const stats = dash?.stats
|
|
|
|
|
const needsAttention = (stats?.reviewQueue ?? 0) > 0
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="shrink-0 w-32 h-32 bg-[var(--c-brand-tint)] rounded-lg flex items-center justify-center text-[var(--c-brand)]">
|
|
|
|
|
<svg width="64" height="64" fill="none" stroke="currentColor" strokeWidth="1" viewBox="0 0 24 24">
|
|
|
|
|
<div className="max-w-5xl mx-auto px-4 md:px-6 py-5 space-y-5">
|
|
|
|
|
<CommandPalette />
|
|
|
|
|
|
|
|
|
|
{/* Greeting + stats */}
|
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4 justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-lg font-semibold text-[var(--c-text)]">{greeting(user?.username)}</h1>
|
|
|
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
|
|
|
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<>
|
|
|
|
|
<Skeleton className="h-14 w-20 rounded-lg" />
|
|
|
|
|
<Skeleton className="h-14 w-20 rounded-lg" />
|
|
|
|
|
<Skeleton className="h-14 w-20 rounded-lg" />
|
|
|
|
|
<Skeleton className="h-14 w-20 rounded-lg" />
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<StatPill label="Repos" value={stats?.repoCount ?? 0} to="/repos" />
|
|
|
|
|
<StatPill label="My PRs" value={stats?.openPRs ?? 0} to="/pulls" />
|
|
|
|
|
<StatPill label="Reviews" value={stats?.reviewQueue ?? 0} to="/pulls" warn />
|
|
|
|
|
<StatPill label="Issues" value={stats?.openIssues ?? 0} to="/issues" />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Main grid */}
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
|
|
|
|
|
|
|
|
|
{/* Left column (wider) */}
|
|
|
|
|
<div className="lg:col-span-3 space-y-4">
|
|
|
|
|
|
|
|
|
|
{/* Needs attention */}
|
|
|
|
|
{(isLoading || needsAttention || (dash?.reviewQueue?.length ?? 0) > 0) && (
|
|
|
|
|
<Section
|
|
|
|
|
title="Needs attention"
|
|
|
|
|
count={dash?.reviewQueue.length}
|
|
|
|
|
icon={
|
|
|
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.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>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? <PanelSkeleton rows={2} /> : (
|
|
|
|
|
<Panel>
|
|
|
|
|
{dash!.reviewQueue.length === 0 ? (
|
|
|
|
|
<Empty message="No PRs awaiting your review." />
|
|
|
|
|
) : (
|
|
|
|
|
dash!.reviewQueue.map(pr => (
|
|
|
|
|
<PRRow key={pr.id} pr={pr} badge={
|
|
|
|
|
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-px rounded-full bg-[var(--c-warning)]/15 text-[var(--c-warning)] border border-[var(--c-warning)]/30 shrink-0">
|
|
|
|
|
Review requested
|
|
|
|
|
</span>
|
|
|
|
|
} />
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</Panel>
|
|
|
|
|
)}
|
|
|
|
|
</Section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* My open PRs */}
|
|
|
|
|
<Section
|
|
|
|
|
title="My pull requests"
|
|
|
|
|
count={dash?.myOpenPRs.length}
|
|
|
|
|
icon={
|
|
|
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
|
|
|
<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>
|
|
|
|
|
}
|
|
|
|
|
action={
|
|
|
|
|
<Link to="/pulls" className="text-[10px] text-[var(--c-brand)] hover:underline">View all</Link>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? <PanelSkeleton rows={3} /> : (
|
|
|
|
|
<Panel>
|
|
|
|
|
{!dash?.myOpenPRs.length ? (
|
|
|
|
|
<Empty
|
|
|
|
|
message="No open pull requests."
|
|
|
|
|
action={<Link to="/repos" className="text-xs text-[var(--c-brand)] hover:underline">Browse your repos →</Link>}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
dash.myOpenPRs.slice(0, 6).map(pr => <PRRow key={pr.id} pr={pr} />)
|
|
|
|
|
)}
|
|
|
|
|
</Panel>
|
|
|
|
|
)}
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Open issues */}
|
|
|
|
|
<Section
|
|
|
|
|
title="My open issues"
|
|
|
|
|
count={dash?.myOpenIssues.length}
|
|
|
|
|
icon={
|
|
|
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? <PanelSkeleton rows={3} /> : (
|
|
|
|
|
<Panel>
|
|
|
|
|
{!dash?.myOpenIssues.length ? (
|
|
|
|
|
<Empty message="No open issues authored by you." />
|
|
|
|
|
) : (
|
|
|
|
|
dash.myOpenIssues.slice(0, 6).map(i => <IssueRow key={i.id} issue={i} />)
|
|
|
|
|
)}
|
|
|
|
|
</Panel>
|
|
|
|
|
)}
|
|
|
|
|
</Section>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Right column */}
|
|
|
|
|
<div className="lg:col-span-2 space-y-4">
|
|
|
|
|
|
|
|
|
|
{/* Active workspaces */}
|
|
|
|
|
<Section
|
|
|
|
|
title="Workspaces"
|
|
|
|
|
icon={
|
|
|
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.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>
|
|
|
|
|
}
|
|
|
|
|
action={
|
|
|
|
|
<Link to="/repos" className="text-[10px] text-[var(--c-brand)] hover:underline">All repos</Link>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? <PanelSkeleton rows={4} /> : (
|
|
|
|
|
<Panel>
|
|
|
|
|
{!dash?.repos.length ? (
|
|
|
|
|
<Empty
|
|
|
|
|
message="No repositories yet."
|
|
|
|
|
action={<Link to="/repos" className="text-xs text-[var(--c-brand)] hover:underline">Create a repository →</Link>}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
(() => {
|
|
|
|
|
// Prioritise recently visited, then fall back to API order
|
|
|
|
|
const recentNames = new Set(recentRepos.map(r => r.name))
|
|
|
|
|
const prioritised = [
|
|
|
|
|
...dash.repos.filter(r => recentNames.has(r.name)),
|
|
|
|
|
...dash.repos.filter(r => !recentNames.has(r.name)),
|
|
|
|
|
].slice(0, 7)
|
|
|
|
|
return prioritised.map(r => <RepoCard key={r.id} repo={r} />)
|
|
|
|
|
})()
|
|
|
|
|
)}
|
|
|
|
|
</Panel>
|
|
|
|
|
)}
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* CI/CD — placeholder until pipelines are implemented */}
|
|
|
|
|
<Section
|
|
|
|
|
title="CI / CD"
|
|
|
|
|
icon={
|
|
|
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
|
|
|
<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>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Panel>
|
|
|
|
|
<div className="px-3.5 py-5 flex flex-col items-center gap-2 text-center">
|
|
|
|
|
<div className="w-8 h-8 rounded-full bg-[var(--c-surface-muted)] flex items-center justify-center">
|
|
|
|
|
<svg width="14" height="14" fill="none" stroke="var(--c-muted)" strokeWidth="2" viewBox="0 0 24 24">
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-[var(--c-muted)]">Pipeline integration coming soon.</p>
|
|
|
|
|
<Link to="/pipelines" className="text-[10px] text-[var(--c-brand)] hover:underline">View pipelines →</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</Panel>
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Quick actions */}
|
|
|
|
|
<Section
|
|
|
|
|
title="Quick actions"
|
|
|
|
|
icon={
|
|
|
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
|
|
|
|
|
</svg>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Panel>
|
|
|
|
|
{[
|
|
|
|
|
{ label: 'New repository', href: '/repos', icon: <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg> },
|
|
|
|
|
{ label: 'Import repository', href: '/repos/import', icon: <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg> },
|
|
|
|
|
{ label: 'Explore projects', href: '/explore', icon: <svg width="13" height="13" 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> },
|
|
|
|
|
{ label: 'Account settings', href: '/settings', icon: <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" 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> },
|
|
|
|
|
].map(a => (
|
|
|
|
|
<Link key={a.href} to={a.href}
|
|
|
|
|
className="flex items-center gap-2.5 px-3.5 py-2.5 text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] hover:text-[var(--c-brand)] transition-colors group">
|
|
|
|
|
<span className="text-[var(--c-muted)] group-hover:text-[var(--c-brand)] transition-colors">{a.icon}</span>
|
|
|
|
|
{a.label}
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</Panel>
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Empty state hero — shown only when user has no repos at all */}
|
|
|
|
|
{!isLoading && dash?.repos.length === 0 && (
|
|
|
|
|
<div className="rounded-lg border border-dashed border-[var(--c-border)] bg-[var(--c-surface)] p-10 text-center space-y-3">
|
|
|
|
|
<div className="w-12 h-12 rounded-full bg-[var(--c-brand-tint)] flex items-center justify-center mx-auto">
|
|
|
|
|
<svg width="24" height="24" fill="none" stroke="var(--c-brand)" strokeWidth="1.5" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25A2.25 2.25 0 0 0 4.5 16.5h15a2.25 2.25 0 0 0 2.25-2.25V9A2.25 2.25 0 0 0 19.5 6.75h-1.06l-2.44-2.44a1.5 1.5 0 0 0-1.061-.44H8.69Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-base font-semibold text-[var(--c-text)]">Welcome to ForgeBucket{user?.username ? `, ${user.username}` : ''}</h2>
|
|
|
|
|
<p className="text-sm text-[var(--c-muted)] mt-1 max-w-sm mx-auto">
|
|
|
|
|
Create your first repository to get started. Your operational dashboard will populate as you work.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-center gap-3">
|
|
|
|
|
<Link to="/repos" className="px-4 py-2 rounded-lg bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)]">
|
|
|
|
|
Create repository
|
|
|
|
|
</Link>
|
|
|
|
|
<Link to="/explore" className="px-4 py-2 rounded-lg border border-[var(--c-border)] text-sm text-[var(--c-text)] font-medium hover:bg-[var(--c-surface-muted)]">
|
|
|
|
|
Explore
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|