Skip to content

Commit d8a66b5

Browse files
authored
Merge pull request #279 from DevanshuNEU/feat/repo-cards-redesign
feat: redesign repo cards -- owner/repo, timestamps, sort tabs (OPE-157)
2 parents cdc7c47 + abbf6b1 commit d8a66b5

6 files changed

Lines changed: 307 additions & 54 deletions

File tree

Lines changed: 237 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,108 @@
11
import { useState, useRef, useMemo } from 'react'
22
import { motion } from 'framer-motion'
3-
import { FolderGit2, Plus } from 'lucide-react'
3+
import { FolderGit2, Plus, Files, FunctionSquare, Clock, MoreVertical, Trash2 } from 'lucide-react'
4+
import { cn } from '@/lib/utils'
5+
import {
6+
DropdownMenu,
7+
DropdownMenuContent,
8+
DropdownMenuItem,
9+
DropdownMenuTrigger,
10+
} from './ui/dropdown-menu'
11+
import {
12+
Dialog,
13+
DialogContent,
14+
DialogDescription,
15+
DialogFooter,
16+
DialogHeader,
17+
DialogTitle,
18+
} from './ui/dialog'
19+
import { Button } from './ui/button'
20+
import { Input } from './ui/input'
21+
import { Tabs, TabsList, TabsTrigger } from './ui/tabs'
422
import type { Repository } from '../types'
523
import { RepoGridSkeleton } from './ui/Skeleton'
624

725
interface RepoListProps {
826
repos: Repository[]
927
selectedRepo: string | null
1028
onSelect: (repoId: string) => void
29+
onDelete?: (repoId: string) => void
1130
onAddClick?: () => void
1231
loading?: boolean
1332
}
1433

15-
const StatusBadge = ({ status }: { status: string }) => {
34+
type SortMode = 'recent' | 'name' | 'size'
35+
36+
/** Extract "owner/repo" from a GitHub URL */
37+
function parseRepoSlug(gitUrl: string): string {
38+
try {
39+
const cleaned = gitUrl.replace(/\.git$/, '')
40+
// Match HTTPS: github.com/owner/repo
41+
const https = cleaned.match(/github\.com\/([^/]+\/[^/]+)/)
42+
if (https) return https[1]
43+
// Match SSH: git@github.com:owner/repo
44+
const ssh = cleaned.match(/github\.com:([^/]+\/[^/]+)/)
45+
if (ssh) return ssh[1]
46+
return ''
47+
} catch {
48+
return ''
49+
}
50+
}
51+
52+
/** Relative time: "2h ago", "3d ago", "just now" */
53+
function timeAgo(dateStr?: string): string {
54+
if (!dateStr) return ''
55+
const now = Date.now()
56+
const then = new Date(dateStr).getTime()
57+
const diff = now - then
58+
if (diff < 0) return ''
59+
60+
const mins = Math.floor(diff / 60_000)
61+
if (mins < 1) return 'just now'
62+
if (mins < 60) return `${mins}m ago`
63+
const hrs = Math.floor(mins / 60)
64+
if (hrs < 24) return `${hrs}h ago`
65+
const days = Math.floor(hrs / 24)
66+
if (days < 30) return `${days}d ago`
67+
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
68+
}
69+
70+
const StatusDot = ({ status }: { status: string }) => {
1671
const isIndexed = status === 'indexed'
17-
72+
const isFailed = status === 'failed'
1873
return (
19-
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border
20-
${isIndexed
21-
? 'bg-primary/10 text-primary border-primary/20'
22-
: 'bg-muted text-muted-foreground border-border'
23-
}`}
24-
>
25-
<span className={`w-1.5 h-1.5 rounded-full ${isIndexed ? 'bg-primary' : 'bg-muted-foreground animate-pulse'}`} />
26-
{isIndexed ? 'Indexed' : 'Pending'}
74+
<span className={cn(
75+
'inline-flex items-center gap-1.5 text-xs',
76+
isIndexed ? 'text-primary' : isFailed ? 'text-destructive' : 'text-muted-foreground',
77+
)}>
78+
<span className={cn(
79+
'w-1.5 h-1.5 rounded-full',
80+
isIndexed ? 'bg-primary' : isFailed ? 'bg-destructive' : 'bg-muted-foreground animate-pulse',
81+
)} />
82+
{isIndexed ? 'Indexed' : isFailed ? 'Failed' : 'Pending'}
2783
</span>
2884
)
2985
}
3086

31-
const RepoCard = ({ repo, index, onSelect }: {
87+
const RepoCard = ({ repo, index, onSelect, onDeleteClick }: {
3288
repo: Repository
3389
index: number
34-
onSelect: () => void
90+
onSelect: () => void
91+
onDeleteClick?: () => void
3592
}) => {
3693
const cardRef = useRef<HTMLButtonElement>(null)
3794
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
3895
const [hovering, setHovering] = useState(false)
96+
const slug = parseRepoSlug(repo.git_url)
97+
const indexed = timeAgo(repo.last_indexed_at)
3998

4099
return (
41100
<motion.button
42101
ref={cardRef}
43-
initial={{ opacity: 0, y: 20 }}
102+
initial={{ opacity: 0, y: 12 }}
44103
animate={{ opacity: 1, y: 0 }}
45-
transition={{ delay: index * 0.05, duration: 0.3 }}
46-
whileHover={{ y: -3 }}
104+
transition={{ delay: index * 0.04, duration: 0.25 }}
105+
whileHover={{ y: -2 }}
47106
onClick={onSelect}
48107
onMouseMove={(e) => {
49108
if (!cardRef.current) return
@@ -56,7 +115,6 @@ const RepoCard = ({ repo, index, onSelect }: {
56115
bg-card border border-border hover:border-primary/40
57116
focus:outline-none focus:ring-2 focus:ring-primary/50 p-5 transition-colors"
58117
>
59-
{/* Mouse glow effect */}
60118
{hovering && (
61119
<div
62120
className="pointer-events-none absolute inset-0"
@@ -65,45 +123,92 @@ const RepoCard = ({ repo, index, onSelect }: {
65123
}}
66124
/>
67125
)}
68-
126+
69127
<div className="relative">
70-
{/* Header */}
71-
<div className="flex items-start justify-between mb-4">
72-
<div className="w-11 h-11 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center group-hover:bg-primary/15 transition-colors">
128+
{/* Top row: icon + status + menu */}
129+
<div className="flex items-start justify-between mb-3">
130+
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center group-hover:bg-primary/15 transition-colors">
73131
<FolderGit2 className="w-5 h-5 text-primary" />
74132
</div>
75-
<StatusBadge status={repo.status} />
133+
<div className="flex items-center gap-1">
134+
<StatusDot status={repo.status} />
135+
{onDeleteClick && (
136+
<DropdownMenu>
137+
<DropdownMenuTrigger asChild>
138+
<button
139+
onClick={(e) => e.stopPropagation()}
140+
className="w-6 h-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
141+
>
142+
<MoreVertical className="w-3.5 h-3.5" />
143+
</button>
144+
</DropdownMenuTrigger>
145+
<DropdownMenuContent align="end">
146+
<DropdownMenuItem
147+
onClick={(e) => { e.stopPropagation(); onDeleteClick() }}
148+
className="text-destructive focus:text-destructive"
149+
>
150+
<Trash2 className="w-3.5 h-3.5 mr-2" />
151+
Delete repository
152+
</DropdownMenuItem>
153+
</DropdownMenuContent>
154+
</DropdownMenu>
155+
)}
156+
</div>
76157
</div>
77158

78-
{/* Title */}
79-
<h3 className="text-lg font-semibold text-foreground mb-0.5 group-hover:text-primary transition-colors">
159+
{/* Repo name + slug */}
160+
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate">
80161
{repo.name}
81162
</h3>
82-
<p className="text-xs text-muted-foreground font-mono mb-5">{repo.branch}</p>
83-
84-
{/* Stats */}
85-
<div className="pt-4 border-t border-border">
86-
<div className="flex items-center justify-between">
87-
<span className="text-sm text-muted-foreground">Files</span>
88-
<span className="text-2xl font-bold text-primary">
89-
{(repo.file_count || 0).toLocaleString()}
163+
{slug && (
164+
<p className="text-xs text-muted-foreground truncate mt-0.5">{slug}</p>
165+
)}
166+
167+
{/* Stats row */}
168+
<div className="flex items-center gap-3 mt-4 pt-3 border-t border-border text-xs text-muted-foreground">
169+
<span className="flex items-center gap-1">
170+
<Files className="w-3 h-3" />
171+
{(repo.file_count || 0).toLocaleString()}
172+
</span>
173+
{repo.function_count != null && repo.function_count > 0 && (
174+
<span className="flex items-center gap-1">
175+
<FunctionSquare className="w-3 h-3" />
176+
{repo.function_count.toLocaleString()}
90177
</span>
91-
</div>
178+
)}
179+
{indexed && (
180+
<span className="flex items-center gap-1 ml-auto">
181+
<Clock className="w-3 h-3" />
182+
{indexed}
183+
</span>
184+
)}
92185
</div>
93186
</div>
94187
</motion.button>
95188
)
96189
}
97190

98-
export function RepoList({ repos, selectedRepo, onSelect, onAddClick, loading }: RepoListProps) {
99-
// Hooks must be called before any conditional returns
191+
export function RepoList({ repos, selectedRepo, onSelect, onDelete, onAddClick, loading }: RepoListProps) {
192+
const [sortMode, setSortMode] = useState<SortMode>('recent')
193+
const [deleteTarget, setDeleteTarget] = useState<Repository | null>(null)
194+
100195
const sortedRepos = useMemo(() => {
101-
return [...repos].sort((a, b) => {
102-
if (a.status === 'indexed' && b.status !== 'indexed') return -1
103-
if (b.status === 'indexed' && a.status !== 'indexed') return 1
104-
return (b.file_count || 0) - (a.file_count || 0)
105-
})
106-
}, [repos])
196+
const sorted = [...repos]
197+
if (sortMode === 'recent') {
198+
sorted.sort((a, b) => {
199+
// Prefer last_indexed_at; use created_at only as tiebreaker
200+
const aIdx = a.last_indexed_at || ''
201+
const bIdx = b.last_indexed_at || ''
202+
if (aIdx !== bIdx) return bIdx.localeCompare(aIdx)
203+
return (b.created_at || '').localeCompare(a.created_at || '')
204+
})
205+
} else if (sortMode === 'name') {
206+
sorted.sort((a, b) => a.name.localeCompare(b.name))
207+
} else {
208+
sorted.sort((a, b) => (b.file_count || 0) - (a.file_count || 0))
209+
}
210+
return sorted
211+
}, [repos, sortMode])
107212

108213
if (loading) return <RepoGridSkeleton count={3} />
109214

@@ -133,15 +238,97 @@ export function RepoList({ repos, selectedRepo, onSelect, onAddClick, loading }:
133238
}
134239

135240
return (
136-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
137-
{sortedRepos.map((repo, index) => (
138-
<RepoCard
139-
key={repo.id}
140-
repo={repo}
141-
index={index}
142-
onSelect={() => onSelect(repo.id)}
143-
/>
144-
))}
241+
<div className="space-y-4">
242+
{/* Sort bar */}
243+
<div className="flex items-center">
244+
<Tabs value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
245+
<TabsList className="h-8">
246+
<TabsTrigger value="recent" className="text-xs px-3">Recent</TabsTrigger>
247+
<TabsTrigger value="name" className="text-xs px-3">Name</TabsTrigger>
248+
<TabsTrigger value="size" className="text-xs px-3">Size</TabsTrigger>
249+
</TabsList>
250+
</Tabs>
251+
<span className="ml-auto text-xs text-muted-foreground">{repos.length} repos</span>
252+
</div>
253+
254+
{/* Grid */}
255+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
256+
{sortedRepos.map((repo, index) => (
257+
<RepoCard
258+
key={repo.id}
259+
repo={repo}
260+
index={index}
261+
onSelect={() => onSelect(repo.id)}
262+
onDeleteClick={onDelete ? () => setDeleteTarget(repo) : undefined}
263+
/>
264+
))}
265+
</div>
266+
267+
{/* Delete confirmation dialog */}
268+
<DeleteConfirmDialog
269+
repo={deleteTarget}
270+
onCancel={() => setDeleteTarget(null)}
271+
onConfirm={() => {
272+
if (deleteTarget && onDelete) {
273+
onDelete(deleteTarget.id)
274+
setDeleteTarget(null)
275+
}
276+
}}
277+
/>
145278
</div>
146279
)
147280
}
281+
282+
283+
export function DeleteConfirmDialog({
284+
repo,
285+
onCancel,
286+
onConfirm,
287+
}: {
288+
repo: Repository | null
289+
onCancel: () => void
290+
onConfirm: () => void
291+
}) {
292+
const [confirmText, setConfirmText] = useState('')
293+
const repoName = repo?.name || ''
294+
const isMatch = confirmText === repoName
295+
296+
return (
297+
<Dialog
298+
open={!!repo}
299+
onOpenChange={(open) => { if (!open) { setConfirmText(''); onCancel() } }}
300+
>
301+
<DialogContent>
302+
<DialogHeader>
303+
<DialogTitle>Delete repository</DialogTitle>
304+
<DialogDescription>
305+
This will permanently remove <strong>{repoName}</strong> and all its
306+
indexed data. This action cannot be undone.
307+
</DialogDescription>
308+
</DialogHeader>
309+
<div className="py-2">
310+
<p className="text-sm text-muted-foreground mb-2">
311+
Type <strong className="text-foreground">{repoName}</strong> to confirm
312+
</p>
313+
<Input
314+
value={confirmText}
315+
onChange={(e) => setConfirmText(e.target.value)}
316+
placeholder={repoName}
317+
aria-label={`Type ${repoName} to confirm deletion`}
318+
autoFocus
319+
/>
320+
</div>
321+
<DialogFooter className="gap-2">
322+
<Button variant="outline" onClick={() => { setConfirmText(''); onCancel() }}>Cancel</Button>
323+
<Button
324+
variant="destructive"
325+
disabled={!isMatch}
326+
onClick={() => { setConfirmText(''); onConfirm() }}
327+
>
328+
Delete {repoName}
329+
</Button>
330+
</DialogFooter>
331+
</DialogContent>
332+
</Dialog>
333+
)
334+
}

frontend/src/components/dashboard/DashboardHome.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,23 @@ export function DashboardHome() {
212212
}
213213

214214
const selectedRepoData = repos.find((r) => r.id === selectedRepo)
215+
const handleDeleteRepo = async (repoId: string) => {
216+
try {
217+
const response = await fetch(`${API_URL}/repos/${repoId}`, {
218+
method: 'DELETE',
219+
headers: { Authorization: `Bearer ${session?.access_token}` },
220+
})
221+
if (!response.ok) throw new Error('Failed to delete')
222+
toast.success('Repository deleted')
223+
refreshRepos()
224+
if (selectedRepo === repoId) setSelectedRepo(null)
225+
} catch (error) {
226+
toast.error('Failed to delete repository', {
227+
description: error instanceof Error ? error.message : 'Please try again',
228+
})
229+
}
230+
}
231+
215232
const isRepoView = selectedRepo && selectedRepoData
216233

217234
return (
@@ -226,6 +243,7 @@ export function DashboardHome() {
226243
selectedRepo={selectedRepo}
227244
maxRepos={maxRepos}
228245
onSelectRepo={(id) => { setSelectedRepo(id); setActiveTab('overview') }}
246+
onDeleteRepo={handleDeleteRepo}
229247
onAddClick={() => setShowAddForm(true)}
230248
onGitHubClick={() => setShowGitHubSelector(true)}
231249
/>
@@ -239,6 +257,7 @@ export function DashboardHome() {
239257
onTabChange={setActiveTab}
240258
onBack={() => { setSelectedRepo(null); setActiveTab('overview') }}
241259
onReindex={handleReindex}
260+
onDelete={() => handleDeleteRepo(selectedRepo)}
242261
/>
243262
)}
244263
</AnimatePresence>

0 commit comments

Comments
 (0)