diff --git a/frontend/src/api/queries/dashboard.ts b/frontend/src/api/queries/dashboard.ts new file mode 100644 index 0000000..d633e1d --- /dev/null +++ b/frontend/src/api/queries/dashboard.ts @@ -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 +export type DashPR = z.infer +export type DashIssue = z.infer +export type DashRepo = z.infer + +export function useDashboard() { + return useQuery({ + queryKey: ['dashboard'], + queryFn: () => api.get('/api/v1/dashboard', dashboardSchema), + refetchInterval: 60_000, + staleTime: 30_000, + }) +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 288046b..f2239cd 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 - - return ( -
- - {/* Hero — only when no repos yet */} - {!reposLoading && !hasRepos && isAuthenticated && ( -
-
- -
-

- Welcome to ForgeBucket{user?.username ? `, ${user.username}` : ''}! -

-

- Get started by creating your first repository, pushing code, and collaborating through pull requests. -

-
- - Create repository - - - Explore - -
-
-
-
- )} - - {/* Recent repositories */} -
-
-

- Recent repositories - - + - -

- View all -
- - {reposLoading ? ( - - ) : !repos?.length ? ( -
-

No repositories yet.

- - Create your first repository → - -
- ) : ( -
- {repos.slice(0, 6).map(r => )} -
- )} -
- - {/* Open pull requests — across all repos */} - {repos && repos.length > 0 && ( -
-
-

Pull requests

-
- ({ owner: r.ownerName, name: r.name }))} /> -
- )} -
- ) +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 PullRequestSummary({ repos }: { repos: { owner: string; name: string }[] }) { - const first = repos[0] - const { data: prs, isLoading } = usePRs(first?.owner ?? '', first?.name ?? '') +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 +} - if (isLoading) return +// ── 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(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 ( + + + + ) + if (t === 'pr') return ( + + + + ) return ( -
-

You have no open pull requests.

-
+ + + + ) + } + + if (!open) { + return ( + ) } return ( -
- {open.slice(0, 5).map(pr => ( - - - +
setOpen(false)}> +
e.stopPropagation()}> +
+ + -
-

{pr.title}

-

- {first.name} · {pr.sourceBranch} → {pr.targetBranch} -

+ 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" /> + esc +
+ {results.length > 0 ? ( +
    + {results.map((res, i) => ( +
  • + 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)]'}`}> + {typeIcon(res.type)} + + {res.label} + {res.sub} + + +
  • + ))} +
+ ) : q ? ( +
No results for "{q}"
+ ) : ( +
+

Quick navigate

+
+ {[ + { label: 'My repos', href: '/repos' }, + { label: 'Open PRs', href: '/pulls' }, + { label: 'Issues', href: '/issues' }, + { label: 'Explore', href: '/explore' }, + { label: 'Settings', href: '/settings' }, + ].map(link => ( + 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} + + ))} +
- + )} +
+ ↑↓ navigate + open + esc close +
+
+
+ ) +} + +// ── Stat pill ───────────────────────────────────────────────────────────────── + +function StatPill({ label, value, to, warn }: { label: string; value: number; to: string; warn?: boolean }) { + return ( + + 0 ? 'text-[var(--c-warning)]' : 'text-[var(--c-text)]'} group-hover:text-[var(--c-brand)] transition-colors`}>{value} + {label} + + ) +} + +// ── Section wrapper ─────────────────────────────────────────────────────────── + +function Section({ title, count, icon, children, action }: { + title: string; count?: number; icon: React.ReactNode; children: React.ReactNode; action?: React.ReactNode +}) { + return ( +
+
+

+ {icon} + {title} + {count !== undefined && count > 0 && ( + {count} + )} +

+ {action} +
+ {children} +
+ ) +} + +// ── PR row ──────────────────────────────────────────────────────────────────── + +function PRRow({ pr, badge }: { pr: DashPR; badge?: React.ReactNode }) { + return ( + + + + +
+
+ {pr.title} + {badge} +
+

+ {pr.ownerName}/{pr.repoName} + {' · '} + {pr.sourceBranch} + {' → '} + {pr.targetBranch} + {' · '} + {timeAgo(pr.updatedAt)} +

+
+ + ) +} + +// ── Issue row ───────────────────────────────────────────────────────────────── + +function IssueRow({ issue }: { issue: DashIssue }) { + return ( + + + + +
+ {issue.title} +

+ {issue.ownerName}/{issue.repoName} + {' · '}#{issue.number} + {' · '} + {timeAgo(issue.updatedAt)} +

+
+ + ) +} + +// ── Repo workspace card ─────────────────────────────────────────────────────── + +function RepoCard({ repo }: { repo: DashRepo }) { + return ( + + +
+
+ {repo.name} + {repo.isPrivate && ( + private + )} +
+
+ {repo.openPrCount > 0 && ( + + + + + {repo.openPrCount} PR{repo.openPrCount !== 1 ? 's' : ''} + + )} + {repo.openIssueCount > 0 && ( + + + + + {repo.openIssueCount} + + )} + {timeAgo(repo.updatedAt)} +
+
+ + + + + ) +} + +// ── Empty state ─────────────────────────────────────────────────────────────── + +function Empty({ message, action }: { message: string; action?: React.ReactNode }) { + return ( +
+

{message}

+ {action &&
{action}
} +
+ ) +} + +// ── Panel wrapper ───────────────────────────────────────────────────────────── + +function Panel({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +// ── Skeleton states ─────────────────────────────────────────────────────────── + +function PanelSkeleton({ rows = 3 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ +
+ + +
+
))}
) } -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 ( -
- - - +
+ + + {/* Greeting + stats */} +
+
+

{greeting(user?.username)}

+

+ {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })} +

+
+ +
+ {isLoading ? ( + <> + + + + + + ) : ( + <> + + + + + + )} +
+
+ + {/* Main grid */} +
+ + {/* Left column (wider) */} +
+ + {/* Needs attention */} + {(isLoading || needsAttention || (dash?.reviewQueue?.length ?? 0) > 0) && ( +
+ + + } + > + {isLoading ? : ( + + {dash!.reviewQueue.length === 0 ? ( + + ) : ( + dash!.reviewQueue.map(pr => ( + + Review requested + + } /> + )) + )} + + )} +
+ )} + + {/* My open PRs */} +
+ + + } + action={ + View all + } + > + {isLoading ? : ( + + {!dash?.myOpenPRs.length ? ( + Browse your repos →} + /> + ) : ( + dash.myOpenPRs.slice(0, 6).map(pr => ) + )} + + )} +
+ + {/* Open issues */} +
+ + + } + > + {isLoading ? : ( + + {!dash?.myOpenIssues.length ? ( + + ) : ( + dash.myOpenIssues.slice(0, 6).map(i => ) + )} + + )} +
+
+ + {/* Right column */} +
+ + {/* Active workspaces */} +
+ + + } + action={ + All repos + } + > + {isLoading ? : ( + + {!dash?.repos.length ? ( + Create a repository →} + /> + ) : ( + (() => { + // 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 => ) + })() + )} + + )} +
+ + {/* CI/CD — placeholder until pipelines are implemented */} +
+ + + } + > + +
+
+ + + +
+

Pipeline integration coming soon.

+ View pipelines → +
+
+
+ + {/* Quick actions */} +
+ + + } + > + + {[ + { label: 'New repository', href: '/repos', icon: }, + { label: 'Import repository', href: '/repos/import', icon: }, + { label: 'Explore projects', href: '/explore', icon: }, + { label: 'Account settings', href: '/settings', icon: }, + ].map(a => ( + + {a.icon} + {a.label} + + ))} + +
+ +
+
+ + {/* Empty state hero — shown only when user has no repos at all */} + {!isLoading && dash?.repos.length === 0 && ( +
+
+ + + +
+
+

Welcome to ForgeBucket{user?.username ? `, ${user.username}` : ''}

+

+ Create your first repository to get started. Your operational dashboard will populate as you work. +

+
+
+ + Create repository + + + Explore + +
+
+ )}
) } diff --git a/internal/api/handlers/dashboard.go b/internal/api/handlers/dashboard.go new file mode 100644 index 0000000..25f093d --- /dev/null +++ b/internal/api/handlers/dashboard.go @@ -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) +} diff --git a/internal/api/router.go b/internal/api/router.go index 4d1fcc3..727b9f1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -51,6 +51,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi prSettingsH := handlers.NewPRSettingsHandler(engine) lfsH := handlers.NewLFSHandler(engine) exploreH := handlers.NewExploreHandler(engine) + dashH := handlers.NewDashboardHandler(engine) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // 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.Get("/me", userH.Me) + r.Get("/dashboard", dashH.Get) // SSH key management r.Get("/user/keys", sshKeyH.List)