Backend — GET /api/v1/dashboard (single authenticated request):

Aggregates repos, open PRs, review queue, open issues server-side
Per-repo PR and issue counts computed in one pass
Review queue pulls PRs where the user is an assigned reviewer (from pr_reviewers table), excluding their own PRs
Frontend — complete redesign of DashboardPage.tsx:

Section	What it shows
Stats bar	Repo count · My PRs · Reviews awaiting · Open issues — each a clickable nav pill
⌘K Command palette	Fuzzy search across repos, PRs, issues with keyboard nav (↑↓ / Enter / Esc), quick-nav shortcuts when empty
Needs attention	Only appears when review queue is non-empty; badges each PR as "Review requested"
My pull requests	Open PRs I authored, with source→target branch, repo context, relative timestamp
My open issues	Issues I filed, linked to the repo issue list
Workspaces	My repos, prioritising recently visited (from useRecentRepos), with PR/issue count badges
CI/CD	Honest placeholder until pipeline integration lands
Quick actions	New repo · Import · Explore · Settings — always one click away
Empty state	Shows only when user has zero repos
This commit is contained in:
2026-05-07 16:36:45 +02:00
parent b624337b4a
commit 7436679eac
4 changed files with 857 additions and 109 deletions
+71
View File
@@ -0,0 +1,71 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
const statsSchema = z.object({
repoCount: z.number(),
openPRs: z.number(),
reviewQueue: z.number(),
openIssues: z.number(),
})
const dashPRSchema = z.object({
id: z.number(),
title: z.string(),
sourceBranch: z.string(),
targetBranch: z.string(),
status: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
repoId: z.number(),
repoName: z.string(),
ownerName: z.string(),
authorId: z.number(),
})
const dashIssueSchema = z.object({
id: z.number(),
number: z.number(),
title: z.string(),
state: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
repoId: z.number(),
repoName: z.string(),
ownerName: z.string(),
})
const dashRepoSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
isPrivate: z.boolean(),
defaultBranch: z.string(),
updatedAt: z.string(),
ownerName: z.string(),
avatarUrl: z.string(),
openPrCount: z.number(),
openIssueCount: z.number(),
})
const dashboardSchema = z.object({
stats: statsSchema,
reviewQueue: z.array(dashPRSchema),
myOpenPRs: z.array(dashPRSchema),
myOpenIssues: z.array(dashIssueSchema),
repos: z.array(dashRepoSchema),
})
export type DashboardData = z.infer<typeof dashboardSchema>
export type DashPR = z.infer<typeof dashPRSchema>
export type DashIssue = z.infer<typeof dashIssueSchema>
export type DashRepo = z.infer<typeof dashRepoSchema>
export function useDashboard() {
return useQuery({
queryKey: ['dashboard'],
queryFn: () => api.get<DashboardData>('/api/v1/dashboard', dashboardSchema),
refetchInterval: 60_000,
staleTime: 30_000,
})
}
+557 -109
View File
@@ -1,133 +1,581 @@
import { Link } from 'react-router-dom' import { useState, useEffect, useRef, useCallback } from 'react'
import { useRepos } from '../api/queries/repos' import { Link, useNavigate } from 'react-router-dom'
import { usePRs } from '../api/queries/prs'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { RepoCard } from '../components/repos/RepoCard' import { useDashboard } from '../api/queries/dashboard'
import { RepoListSkeleton, PRListSkeleton } from '../ui/Skeleton' 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() { // ── Utilities ─────────────────────────────────────────────────────────────────
const { user, isAuthenticated } = useAuth()
const { data: repos, isLoading: reposLoading } = useRepos()
const hasRepos = repos && repos.length > 0 function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
return ( const min = Math.floor(diff / 60_000)
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-8"> if (min < 1) return 'just now'
if (min < 60) return `${min}m ago`
{/* Hero — only when no repos yet */} const hr = Math.floor(min / 60)
{!reposLoading && !hasRepos && isAuthenticated && ( if (hr < 24) return `${hr}h ago`
<div className="rounded-lg border border-[var(--c-border)] bg-[var(--c-surface)] overflow-hidden"> const d = Math.floor(hr / 24)
<div className="flex items-center gap-8 p-8"> if (d < 30) return `${d}d ago`
<HeroIllustration /> return `${Math.floor(d / 30)}mo ago`
<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
</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>
) : (
<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-[var(--c-text)]">Pull requests</h2>
</div>
<PullRequestSummary repos={repos.map(r => ({ owner: r.ownerName, name: r.name }))} />
</section>
)}
</div>
)
} }
function PullRequestSummary({ repos }: { repos: { owner: string; name: string }[] }) { function greeting(username?: string): string {
const first = repos[0] const h = new Date().getHours()
const { data: prs, isLoading } = usePRs(first?.owner ?? '', first?.name ?? '') const time = h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening'
return username ? `${time}, ${username}` : time
}
if (isLoading) return <PRListSkeleton /> // ── Command palette ────────────────────────────────────────────────────────────
const open = prs?.filter(p => p.status === 'open') ?? [] interface CmdResult {
type: 'repo' | 'pr' | 'issue'
label: string
sub: string
href: string
}
if (!open.length) { 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 ( return (
<div className="border border-[var(--c-border)] rounded p-6 text-center bg-[var(--c-surface)]"> <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<p className="text-sm text-[var(--c-muted)]">You have no open pull requests.</p> <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" />
</div> </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 ( return (
<div className="flex flex-col gap-2"> <div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/40 backdrop-blur-sm" onClick={() => setOpen(false)}>
{open.slice(0, 5).map(pr => ( <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()}>
<Link <div className="flex items-center gap-2.5 px-4 py-3 border-b border-[var(--c-border)]">
key={pr.id} <svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="2" viewBox="0 0 24 24">
to={`/repos/${first.owner}/${first.name}/pulls/${pr.id}`} <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" />
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">
<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> </svg>
<div className="flex-1 min-w-0"> <input ref={inputRef} value={q} onChange={e => setQ(e.target.value)} onKeyDown={onKey}
<p className="text-sm font-medium text-[var(--c-text)] truncate">{pr.title}</p> placeholder="Search repos, PRs, issues…"
<p className="text-xs text-[var(--c-muted)] mt-0.5"> className="flex-1 bg-transparent text-sm text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none" />
{first.name} · {pr.sourceBranch} {pr.targetBranch} <kbd className="text-[10px] font-mono text-[var(--c-subtle)] border border-[var(--c-border)] rounded px-1.5 py-0.5">esc</kbd>
</p> </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>
</li>
))}
</ul>
) : q ? (
<div className="px-4 py-6 text-center text-sm text-[var(--c-muted)]">No results for "{q}"</div>
) : (
<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> </div>
</Link> )}
<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>
)
}
// ── 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-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>
)
}
// ── PR row ────────────────────────────────────────────────────────────────────
function PRRow({ pr, badge }: { pr: DashPR; badge?: React.ReactNode }) {
return (
<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">
<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">
<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> </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 ( 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)]"> <div className="max-w-5xl mx-auto px-4 md:px-6 py-5 space-y-5">
<svg width="64" height="64" fill="none" stroke="currentColor" strokeWidth="1" viewBox="0 0 24 24"> <CommandPalette />
<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> {/* 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> </div>
) )
} }
+227
View File
@@ -0,0 +1,227 @@
package handlers
import (
"net/http"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type DashboardHandler struct{ db *xorm.Engine }
func NewDashboardHandler(db *xorm.Engine) *DashboardHandler { return &DashboardHandler{db: db} }
// ── Response shapes ───────────────────────────────────────────────────────────
type dashStats struct {
RepoCount int `json:"repoCount"`
OpenPRs int `json:"openPRs"`
ReviewQueue int `json:"reviewQueue"`
OpenIssues int `json:"openIssues"`
}
type dashPR struct {
ID int64 `json:"id"`
Title string `json:"title"`
SourceBranch string `json:"sourceBranch"`
TargetBranch string `json:"targetBranch"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
RepoID int64 `json:"repoId"`
RepoName string `json:"repoName"`
OwnerName string `json:"ownerName"`
AuthorID int64 `json:"authorId"`
}
type dashIssue struct {
ID int64 `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
RepoID int64 `json:"repoId"`
RepoName string `json:"repoName"`
OwnerName string `json:"ownerName"`
}
type dashRepo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsPrivate bool `json:"isPrivate"`
DefaultBranch string `json:"defaultBranch"`
UpdatedAt string `json:"updatedAt"`
OwnerName string `json:"ownerName"`
AvatarURL string `json:"avatarUrl"`
OpenPRCount int `json:"openPrCount"`
OpenIssueCount int `json:"openIssueCount"`
}
type dashboardResponse struct {
Stats dashStats `json:"stats"`
ReviewQueue []dashPR `json:"reviewQueue"`
MyOpenPRs []dashPR `json:"myOpenPRs"`
MyOpenIssues []dashIssue `json:"myOpenIssues"`
Repos []dashRepo `json:"repos"`
}
// ── Handler ───────────────────────────────────────────────────────────────────
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.UserIDFromContext(r.Context())
// 1. Repos owned by this user.
var repos []models.Repository
h.db.Where("owner_id = ?", userID).Desc("updated_at").Find(&repos)
repoIDs := make([]int64, len(repos))
repoByID := make(map[int64]models.Repository, len(repos))
for i, rp := range repos {
repoIDs[i] = rp.ID
repoByID[rp.ID] = rp
}
// Owner username — needed for URLs.
var owner models.User
h.db.ID(userID).Cols("id", "username", "avatar_url").Get(&owner)
// 2. All open PRs across user repos.
var allOpenPRs []models.PullRequest
if len(repoIDs) > 0 {
h.db.In("repo_id", repoIDs).Where("status = 'open'").Desc("updated_at").Find(&allOpenPRs)
}
// 3. PRs where user is assigned as reviewer (and PR is open).
var reviewerRows []models.PrReviewer
h.db.Where("user_id = ?", userID).Find(&reviewerRows)
reviewPRIDs := make([]int64, 0, len(reviewerRows))
for _, rv := range reviewerRows {
reviewPRIDs = append(reviewPRIDs, rv.PRID)
}
var reviewPRs []models.PullRequest
if len(reviewPRIDs) > 0 {
h.db.In("id", reviewPRIDs).Where("status = 'open' AND author_id != ?", userID).
Desc("updated_at").Find(&reviewPRs)
}
// 4. Open issues authored by user across their repos.
var openIssues []models.Issue
if len(repoIDs) > 0 {
h.db.In("repo_id", repoIDs).Where("author_id = ? AND state = 'open'", userID).
Desc("updated_at").Limit(20).Find(&openIssues)
}
// 5. Build per-repo counters.
prCountByRepo := make(map[int64]int)
for _, pr := range allOpenPRs {
prCountByRepo[pr.RepoID]++
}
issueCountByRepo := make(map[int64]int)
for _, iss := range openIssues {
issueCountByRepo[iss.RepoID]++
}
// 6. Separate my PRs from the full open list.
var myOpenPRs []models.PullRequest
for _, pr := range allOpenPRs {
if pr.AuthorID == userID {
myOpenPRs = append(myOpenPRs, pr)
}
}
// ── Build response ─────────────────────────────────────────────────────────
toDashPR := func(pr models.PullRequest) dashPR {
rp := repoByID[pr.RepoID]
return dashPR{
ID: pr.ID,
Title: pr.Title,
SourceBranch: pr.SourceBranch,
TargetBranch: pr.TargetBranch,
Status: string(pr.Status),
CreatedAt: pr.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pr.UpdatedAt.Format("2006-01-02T15:04:05Z"),
RepoID: pr.RepoID,
RepoName: rp.Name,
OwnerName: owner.Username,
AuthorID: pr.AuthorID,
}
}
// For review queue PRs that may be on OTHER users' repos, look up owner.
toReviewPR := func(pr models.PullRequest) dashPR {
rp := repoByID[pr.RepoID]
dp := toDashPR(pr)
if rp.ID == 0 {
// PR is on a repo the user doesn't own (they're just a member).
var foreignRepo models.Repository
if found, _ := h.db.ID(pr.RepoID).Get(&foreignRepo); found {
var foreignOwner models.User
h.db.ID(foreignRepo.OwnerID).Cols("username").Get(&foreignOwner)
dp.RepoName = foreignRepo.Name
dp.OwnerName = foreignOwner.Username
}
}
return dp
}
dashRepos := make([]dashRepo, 0, len(repos))
for _, rp := range repos {
dashRepos = append(dashRepos, dashRepo{
ID: rp.ID,
Name: rp.Name,
Description: rp.Description,
IsPrivate: rp.IsPrivate,
DefaultBranch: rp.DefaultBranch,
UpdatedAt: rp.UpdatedAt.Format("2006-01-02T15:04:05Z"),
OwnerName: owner.Username,
AvatarURL: "/api/v1/repos/" + owner.Username + "/" + rp.Name + "/avatar",
OpenPRCount: prCountByRepo[rp.ID],
OpenIssueCount: issueCountByRepo[rp.ID],
})
}
myPRDash := make([]dashPR, 0, len(myOpenPRs))
for _, pr := range myOpenPRs {
myPRDash = append(myPRDash, toDashPR(pr))
}
reviewQueue := make([]dashPR, 0, len(reviewPRs))
for _, pr := range reviewPRs {
reviewQueue = append(reviewQueue, toReviewPR(pr))
}
issueDash := make([]dashIssue, 0, len(openIssues))
for _, iss := range openIssues {
rp := repoByID[iss.RepoID]
issueDash = append(issueDash, dashIssue{
ID: iss.ID,
Number: iss.Number,
Title: iss.Title,
State: string(iss.State),
CreatedAt: iss.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: iss.UpdatedAt.Format("2006-01-02T15:04:05Z"),
RepoID: iss.RepoID,
RepoName: rp.Name,
OwnerName: owner.Username,
})
}
resp := dashboardResponse{
Stats: dashStats{
RepoCount: len(repos),
OpenPRs: len(myOpenPRs),
ReviewQueue: len(reviewPRs),
OpenIssues: len(openIssues),
},
ReviewQueue: reviewQueue,
MyOpenPRs: myPRDash,
MyOpenIssues: issueDash,
Repos: dashRepos,
}
jsonOK(w, resp)
}
+2
View File
@@ -51,6 +51,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
prSettingsH := handlers.NewPRSettingsHandler(engine) prSettingsH := handlers.NewPRSettingsHandler(engine)
lfsH := handlers.NewLFSHandler(engine) lfsH := handlers.NewLFSHandler(engine)
exploreH := handlers.NewExploreHandler(engine) exploreH := handlers.NewExploreHandler(engine)
dashH := handlers.NewDashboardHandler(engine)
// ── Git smart-HTTP transport ─────────────────────────────────────────────── // ── Git smart-HTTP transport ───────────────────────────────────────────────
// These routes MUST be registered before the SPA catch-all and outside CSRF. // These routes MUST be registered before the SPA catch-all and outside CSRF.
@@ -105,6 +106,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.Use(auth.Require) r.Use(auth.Require)
r.Get("/me", userH.Me) r.Get("/me", userH.Me)
r.Get("/dashboard", dashH.Get)
// SSH key management // SSH key management
r.Get("/user/keys", sshKeyH.List) r.Get("/user/keys", sshKeyH.List)