0310986644
Update — PATCH /{prID} edits title and/or body, validates title non-empty, returns prWithReviewers
Reopen — POST /{prID}/reopen transitions closed → open, fires webhook
Close now returns prWithReviewers and fires a webhook
Merge already existed; no changes needed
Frontend — PRDetailPage.tsx full rewrite:
Inline title editing — pencil icon (visible to author/admin when open), Enter to save, Esc to cancel
Inline body editing — same pattern in the description panel
Merge sidebar — radio buttons for allowed strategies (fetched from repo's merge strategy settings), "Merge pull request" button in Bitbucket purple, "Close without merging" below it
Status banner — merged (purple) or closed (grey) with the date, shown below the description
File list — scrollable +N −N table above the diff viewer showing all changed files with addition/deletion counts
Reopen button — appears in the sidebar when the PR is closed
Reviewers panel — lists assigned reviewers with avatars/initials
Details panel — from/into branches, opened date, last updated
Quick links — back to all PRs, open new PR
PRsPage.tsx — now shows real data:
Two tabs: "My pull requests" and "Awaiting my review" (with live counts from dashboard)
Per-repo quick links at the bottom showing open PR count badges
463 lines
22 KiB
TypeScript
463 lines
22 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||
import { usePR, useMergePR, useClosePR, useReopenPR, useUpdatePR } from '../api/queries/prs'
|
||
import { useRepoDiff } from '../api/queries/repos'
|
||
import { useMergeStrategies } from '../api/queries/workflow'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { DiffViewer } from '../components/diff/DiffViewer'
|
||
import { Skeleton } from '../ui/Skeleton'
|
||
import { cn } from '../lib/utils'
|
||
import type { PRReviewer } from '../types/api'
|
||
|
||
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 formatDate(iso: string): string {
|
||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||
}
|
||
|
||
// ── Status badge ──────────────────────────────────────────────────────────────
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const styles = {
|
||
open: { bg: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]', dot: 'bg-[#00875A]', label: 'Open' },
|
||
merged: { bg: 'bg-[#EAE6FF] border-[#C0B6F2] text-[#403294]', dot: 'bg-[#6554C0]', label: 'Merged' },
|
||
closed: { bg: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', dot: 'bg-[var(--c-muted)]', label: 'Closed' },
|
||
}[status] ?? { bg: '', dot: '', label: status }
|
||
|
||
return (
|
||
<span className={cn('inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full border', styles.bg)}>
|
||
<span className={cn('w-1.5 h-1.5 rounded-full', styles.dot)} />
|
||
{styles.label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ── Reviewer avatar ───────────────────────────────────────────────────────────
|
||
|
||
function ReviewerAvatar({ rv }: { rv: PRReviewer }) {
|
||
const color = `hsl(${(rv.username.charCodeAt(0) * 37) % 360},55%,45%)`
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
{rv.avatarUrl ? (
|
||
<img src={rv.avatarUrl} alt={rv.username} className="w-6 h-6 rounded-full object-cover shrink-0" />
|
||
) : (
|
||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-white text-[10px] font-bold shrink-0" style={{ backgroundColor: color }}>
|
||
{rv.username[0].toUpperCase()}
|
||
</div>
|
||
)}
|
||
<span className="text-xs text-[var(--c-text)]">{rv.username}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Merge strategy selector ───────────────────────────────────────────────────
|
||
|
||
const STRATEGIES = [
|
||
{ value: 'merge', label: 'Merge commit', desc: 'Preserve all commits with a merge commit' },
|
||
{ value: 'squash', label: 'Squash and merge', desc: 'Combine all commits into one' },
|
||
{ value: 'rebase', label: 'Rebase and merge', desc: 'Rebase commits onto the target branch' },
|
||
] as const
|
||
|
||
export default function PRDetailPage() {
|
||
const { owner = '', repo = '', prId = '' } = useParams<{ owner: string; repo: string; prId: string }>()
|
||
const navigate = useNavigate()
|
||
const { user } = useAuth()
|
||
const numId = parseInt(prId, 10)
|
||
|
||
const { data: pr, isLoading, isError } = usePR(owner, repo, numId)
|
||
const { data: strategies } = useMergeStrategies(owner, repo)
|
||
const { data: diffs = [] } = useRepoDiff(owner, repo, pr?.targetBranch ?? '', pr?.sourceBranch ?? '')
|
||
|
||
const merge = useMergePR(owner, repo)
|
||
const close = useClosePR(owner, repo)
|
||
const reopen = useReopenPR(owner, repo)
|
||
const update = useUpdatePR(owner, repo, numId)
|
||
|
||
// Merge strategy
|
||
const [strategy, setStrategy] = useState<'merge' | 'squash' | 'rebase'>('merge')
|
||
const [mergeError, setMergeError] = useState('')
|
||
|
||
// Inline title editing
|
||
const [editingTitle, setEditingTitle] = useState(false)
|
||
const [titleDraft, setTitleDraft] = useState('')
|
||
const [editingBody, setEditingBody] = useState(false)
|
||
const [bodyDraft, setBodyDraft] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (pr) {
|
||
setTitleDraft(pr.title)
|
||
setBodyDraft(pr.body)
|
||
// Set default strategy to first allowed one
|
||
if (strategies) {
|
||
if (strategies.allowMergeCommit) setStrategy('merge')
|
||
else if (strategies.allowSquash) setStrategy('squash')
|
||
else if (strategies.allowRebase) setStrategy('rebase')
|
||
}
|
||
}
|
||
}, [pr, strategies])
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
|
||
<Skeleton className="h-4 w-48 rounded" />
|
||
<Skeleton className="h-7 w-3/4 rounded" />
|
||
<Skeleton className="h-4 w-56 rounded" />
|
||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_272px] gap-6 mt-6">
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-28 rounded" />
|
||
<Skeleton className="h-64 rounded" />
|
||
</div>
|
||
<div className="space-y-3">
|
||
<Skeleton className="h-32 rounded" />
|
||
<Skeleton className="h-24 rounded" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (isError || !pr) {
|
||
return (
|
||
<div className="max-w-5xl mx-auto px-4 py-12 text-center">
|
||
<p className="text-4xl font-bold text-[var(--c-border)] mb-3">404</p>
|
||
<p className="text-sm text-[var(--c-muted)] mb-4">Pull request not found.</p>
|
||
<Link to={`/repos/${owner}/${repo}/pulls`} className="text-sm text-[var(--c-brand)] hover:underline">
|
||
← Back to pull requests
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const isAuthor = user?.id === pr.authorId
|
||
const canEdit = isAuthor || user?.isAdmin
|
||
const isOpen = pr.status === 'open'
|
||
const isMerged = pr.status === 'merged'
|
||
const isClosed = pr.status === 'closed'
|
||
|
||
const allowedStrategies = STRATEGIES.filter(s => {
|
||
if (!strategies) return true
|
||
if (s.value === 'merge') return strategies.allowMergeCommit
|
||
if (s.value === 'squash') return strategies.allowSquash
|
||
if (s.value === 'rebase') return strategies.allowRebase
|
||
return false
|
||
})
|
||
|
||
async function handleMerge() {
|
||
setMergeError('')
|
||
try {
|
||
await merge.mutateAsync({ prId: numId, strategy })
|
||
navigate(`/repos/${owner}/${repo}/pulls`)
|
||
} catch (e: any) {
|
||
setMergeError(e.message ?? 'Merge failed.')
|
||
}
|
||
}
|
||
|
||
async function saveTitle() {
|
||
if (titleDraft.trim() === pr!.title) { setEditingTitle(false); return }
|
||
await update.mutateAsync({ title: titleDraft.trim() })
|
||
setEditingTitle(false)
|
||
}
|
||
|
||
async function saveBody() {
|
||
if (bodyDraft === pr!.body) { setEditingBody(false); return }
|
||
await update.mutateAsync({ body: bodyDraft })
|
||
setEditingBody(false)
|
||
}
|
||
|
||
const statLine = (
|
||
<p className="text-xs text-[var(--c-muted)] mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||
<span>#{pr.id}</span>
|
||
<span>·</span>
|
||
<span className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">{pr.sourceBranch}</span>
|
||
<span className="text-[var(--c-subtle)]">→</span>
|
||
<span className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">{pr.targetBranch}</span>
|
||
<span>·</span>
|
||
<span>opened {timeAgo(pr.createdAt)}</span>
|
||
{pr.updatedAt !== pr.createdAt && <><span>·</span><span>updated {timeAgo(pr.updatedAt)}</span></>}
|
||
</p>
|
||
)
|
||
|
||
return (
|
||
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-5">
|
||
{/* Breadcrumb */}
|
||
<div className="flex items-center gap-1 text-sm flex-wrap">
|
||
<Link to={`/repos/${owner}/${repo}`} className="text-[var(--c-brand)] hover:underline">{repo}</Link>
|
||
<span className="text-[var(--c-muted)]">/</span>
|
||
<Link to={`/repos/${owner}/${repo}/pulls`} className="text-[var(--c-brand)] hover:underline">Pull requests</Link>
|
||
<span className="text-[var(--c-muted)]">/</span>
|
||
<span className="text-[var(--c-text)]">#{pr.id}</span>
|
||
</div>
|
||
|
||
{/* Title row */}
|
||
<div>
|
||
{editingTitle ? (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
value={titleDraft}
|
||
onChange={e => setTitleDraft(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||
className="flex-1 text-xl font-semibold border border-[var(--c-brand-focus)] rounded px-3 py-1.5 bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none"
|
||
autoFocus
|
||
/>
|
||
<button onClick={saveTitle} disabled={update.isPending}
|
||
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
||
Save
|
||
</button>
|
||
<button onClick={() => setEditingTitle(false)}
|
||
className="px-3 py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-start gap-3 flex-wrap">
|
||
<h1 className="text-xl font-semibold text-[var(--c-text)] flex-1 min-w-0">{pr.title}</h1>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<StatusBadge status={pr.status} />
|
||
{canEdit && isOpen && (
|
||
<button onClick={() => setEditingTitle(true)}
|
||
className="p-1.5 rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors"
|
||
title="Edit title">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{statLine}
|
||
</div>
|
||
|
||
{/* Main 2-col layout */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_272px] gap-6 items-start">
|
||
|
||
{/* ── Left: description + diff ── */}
|
||
<div className="space-y-5 min-w-0">
|
||
|
||
{/* Description */}
|
||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Description</span>
|
||
{canEdit && isOpen && !editingBody && (
|
||
<button onClick={() => setEditingBody(true)}
|
||
className="flex items-center gap-1 text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] transition-colors">
|
||
<svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
|
||
</svg>
|
||
Edit
|
||
</button>
|
||
)}
|
||
</div>
|
||
{editingBody ? (
|
||
<div className="p-4 space-y-3">
|
||
<textarea value={bodyDraft} onChange={e => setBodyDraft(e.target.value)}
|
||
rows={8} className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] resize-y" />
|
||
<div className="flex gap-2">
|
||
<button onClick={saveBody} disabled={update.isPending}
|
||
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
||
{update.isPending ? 'Saving…' : 'Save'}
|
||
</button>
|
||
<button onClick={() => { setBodyDraft(pr.body); setEditingBody(false) }}
|
||
className="px-3 py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="px-4 py-3">
|
||
{pr.body ? (
|
||
<p className="text-sm text-[var(--c-text)] whitespace-pre-wrap leading-relaxed">{pr.body}</p>
|
||
) : (
|
||
<p className="text-sm text-[var(--c-muted)] italic">No description provided.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Status banner for non-open PRs */}
|
||
{(isMerged || isClosed) && (
|
||
<div className={cn(
|
||
'flex items-center gap-3 px-4 py-3 rounded-lg border text-sm font-medium',
|
||
isMerged ? 'border-[#C0B6F2] bg-[#EAE6FF] text-[#403294]' : 'border-[var(--c-border)] bg-[var(--c-surface-muted)] text-[var(--c-muted)]',
|
||
)}>
|
||
{isMerged ? (
|
||
<>
|
||
<svg width="16" height="16" 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>
|
||
Merged on {formatDate(pr.updatedAt)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||
</svg>
|
||
Closed on {formatDate(pr.updatedAt)} without merging
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Files changed */}
|
||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">
|
||
Files changed
|
||
{diffs.length > 0 && (
|
||
<span className="ml-1.5 text-[var(--c-text)]">({diffs.length})</span>
|
||
)}
|
||
</span>
|
||
<div className="flex items-center gap-2 text-xs">
|
||
{diffs.length > 0 && (
|
||
<>
|
||
<span className="text-[#006644]">+{diffs.reduce((s, f) => s + f.additions, 0)}</span>
|
||
<span className="text-[var(--c-danger)]">−{diffs.reduce((s, f) => s + f.deletions, 0)}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{diffs.length === 0 ? (
|
||
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
|
||
{pr.sourceBranch && pr.targetBranch
|
||
? 'No differences detected between branches.'
|
||
: 'Diff unavailable — branches may not exist locally.'}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* File list */}
|
||
<ul className="border-b border-[var(--c-border)] divide-y divide-[var(--c-border)] max-h-40 overflow-y-auto">
|
||
{diffs.map(f => (
|
||
<li key={f.path} className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono hover:bg-[var(--c-surface-muted)]">
|
||
<span className="text-[#006644] tabular-nums w-8 text-right shrink-0">+{f.additions}</span>
|
||
<span className="text-[var(--c-danger)] tabular-nums w-8 text-right shrink-0">−{f.deletions}</span>
|
||
<span className="text-[var(--c-text)] truncate">{f.path}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<DiffViewer files={diffs} />
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Right sidebar ── */}
|
||
<div className="space-y-4">
|
||
|
||
{/* Merge / close / reopen actions */}
|
||
{isOpen && (
|
||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Merge pull request</span>
|
||
</div>
|
||
<div className="p-4 space-y-3">
|
||
{allowedStrategies.length > 0 ? (
|
||
<>
|
||
<div className="space-y-1.5">
|
||
{allowedStrategies.map(s => (
|
||
<label key={s.value} className="flex items-start gap-2.5 cursor-pointer group">
|
||
<input type="radio" name="strategy" value={s.value}
|
||
checked={strategy === s.value}
|
||
onChange={() => setStrategy(s.value)}
|
||
className="mt-0.5 accent-[var(--c-brand)]" />
|
||
<div>
|
||
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] transition-colors">{s.label}</span>
|
||
<p className="text-xs text-[var(--c-muted)]">{s.desc}</p>
|
||
</div>
|
||
</label>
|
||
))}
|
||
</div>
|
||
{mergeError && <p className="text-xs text-[var(--c-danger)]">{mergeError}</p>}
|
||
<button onClick={handleMerge} disabled={merge.isPending}
|
||
className="w-full py-2 rounded bg-[#6554C0] text-white text-sm font-medium hover:bg-[#5243AA] disabled:opacity-50 transition-colors">
|
||
{merge.isPending ? 'Merging…' : 'Merge pull request'}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<p className="text-xs text-[var(--c-muted)]">No merge strategies are enabled for this repository.</p>
|
||
)}
|
||
<button onClick={() => close.mutate(numId)} disabled={close.isPending}
|
||
className="w-full py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-muted)] hover:text-[var(--c-danger)] hover:border-[var(--c-danger)] disabled:opacity-50 transition-colors">
|
||
{close.isPending ? 'Closing…' : 'Close without merging'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isClosed && (
|
||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||
<div className="p-4">
|
||
<button onClick={() => reopen.mutate(numId)} disabled={reopen.isPending}
|
||
className="w-full py-2 rounded border border-[var(--c-brand)] text-[var(--c-brand)] text-sm font-medium hover:bg-[var(--c-brand-tint)] disabled:opacity-50 transition-colors">
|
||
{reopen.isPending ? 'Reopening…' : 'Reopen pull request'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Reviewers */}
|
||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Reviewers</span>
|
||
</div>
|
||
<div className="p-3 space-y-2">
|
||
{pr.reviewers.length === 0 ? (
|
||
<p className="text-xs text-[var(--c-muted)]">No reviewers assigned.</p>
|
||
) : (
|
||
pr.reviewers.map(rv => <ReviewerAvatar key={rv.userId} rv={rv} />)
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Metadata */}
|
||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Details</span>
|
||
</div>
|
||
<dl className="p-3 space-y-2.5 text-xs">
|
||
{[
|
||
{ label: 'From', value: <span className="font-mono text-[var(--c-text)]">{pr.sourceBranch}</span> },
|
||
{ label: 'Into', value: <span className="font-mono text-[var(--c-text)]">{pr.targetBranch}</span> },
|
||
{ label: 'Opened', value: <span className="text-[var(--c-text)]" title={pr.createdAt}>{formatDate(pr.createdAt)}</span> },
|
||
{ label: 'Updated', value: <span className="text-[var(--c-text)]" title={pr.updatedAt}>{timeAgo(pr.updatedAt)}</span> },
|
||
].map(row => (
|
||
<div key={row.label} className="flex items-center justify-between gap-2">
|
||
<dt className="text-[var(--c-muted)] shrink-0">{row.label}</dt>
|
||
<dd className="truncate">{row.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</div>
|
||
|
||
{/* Quick links */}
|
||
<div className="flex flex-col gap-1">
|
||
<Link to={`/repos/${owner}/${repo}/pulls`}
|
||
className="flex items-center gap-2 text-xs text-[var(--c-muted)] hover:text-[var(--c-brand)] transition-colors px-1 py-0.5">
|
||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||
</svg>
|
||
All pull requests
|
||
</Link>
|
||
<Link to={`/repos/${owner}/${repo}/pulls/new`}
|
||
className="flex items-center gap-2 text-xs text-[var(--c-muted)] hover:text-[var(--c-brand)] transition-colors px-1 py-0.5">
|
||
<svg width="12" height="12" 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>
|
||
New pull request
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|