Skip to content

Commit 3bd3470

Browse files
committed
feat: redesign repo cards with owner/repo, timestamps, sort tabs (OPE-157)
Repo cards now show: - owner/repo slug extracted from git_url (e.g. Effect-TS/effect) - File count + function count side by side - 'Indexed 2h ago' relative timestamp from last_indexed_at - Status dot instead of full badge (cleaner) Sort bar above the grid: - Recent (default): sorted by last_indexed_at descending - Name: alphabetical - Size: largest file count first - Repo count shown on right side Repository type updated with function_count, created_at, last_indexed_at. These fields already exist in the backend (select *), just weren't typed. 230 lines, same glow effect, premium feel.
1 parent c3288da commit 3bd3470

2 files changed

Lines changed: 134 additions & 48 deletions

File tree

Lines changed: 131 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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 } from 'lucide-react'
4+
import { cn } from '@/lib/utils'
45
import type { Repository } from '../types'
56
import { RepoGridSkeleton } from './ui/Skeleton'
67

@@ -12,38 +13,71 @@ interface RepoListProps {
1213
loading?: boolean
1314
}
1415

15-
const StatusBadge = ({ status }: { status: string }) => {
16+
type SortMode = 'recent' | 'name' | 'size'
17+
18+
/** Extract "owner/repo" from a GitHub URL */
19+
function parseRepoSlug(gitUrl: string): string {
20+
try {
21+
const cleaned = gitUrl.replace(/\.git$/, '')
22+
const match = cleaned.match(/github\.com\/([^/]+\/[^/]+)/)
23+
return match ? match[1] : ''
24+
} catch {
25+
return ''
26+
}
27+
}
28+
29+
/** Relative time: "2h ago", "3d ago", "just now" */
30+
function timeAgo(dateStr?: string): string {
31+
if (!dateStr) return ''
32+
const now = Date.now()
33+
const then = new Date(dateStr).getTime()
34+
const diff = now - then
35+
if (diff < 0) return ''
36+
37+
const mins = Math.floor(diff / 60_000)
38+
if (mins < 1) return 'just now'
39+
if (mins < 60) return `${mins}m ago`
40+
const hrs = Math.floor(mins / 60)
41+
if (hrs < 24) return `${hrs}h ago`
42+
const days = Math.floor(hrs / 24)
43+
if (days < 30) return `${days}d ago`
44+
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
45+
}
46+
47+
const StatusDot = ({ status }: { status: string }) => {
1648
const isIndexed = status === 'indexed'
17-
1849
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'}`} />
50+
<span className={cn(
51+
'inline-flex items-center gap-1.5 text-xs',
52+
isIndexed ? 'text-primary' : 'text-muted-foreground',
53+
)}>
54+
<span className={cn(
55+
'w-1.5 h-1.5 rounded-full',
56+
isIndexed ? 'bg-primary' : 'bg-muted-foreground animate-pulse',
57+
)} />
2658
{isIndexed ? 'Indexed' : 'Pending'}
2759
</span>
2860
)
2961
}
3062

31-
const RepoCard = ({ repo, index, onSelect }: {
63+
const RepoCard = ({ repo, index, onSelect }: {
3264
repo: Repository
3365
index: number
34-
onSelect: () => void
66+
onSelect: () => void
3567
}) => {
3668
const cardRef = useRef<HTMLButtonElement>(null)
3769
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
3870
const [hovering, setHovering] = useState(false)
71+
const slug = parseRepoSlug(repo.git_url)
72+
const indexed = timeAgo(repo.last_indexed_at)
3973

4074
return (
4175
<motion.button
4276
ref={cardRef}
43-
initial={{ opacity: 0, y: 20 }}
77+
initial={{ opacity: 0, y: 12 }}
4478
animate={{ opacity: 1, y: 0 }}
45-
transition={{ delay: index * 0.05, duration: 0.3 }}
46-
whileHover={{ y: -3 }}
79+
transition={{ delay: index * 0.04, duration: 0.25 }}
80+
whileHover={{ y: -2 }}
4781
onClick={onSelect}
4882
onMouseMove={(e) => {
4983
if (!cardRef.current) return
@@ -56,7 +90,6 @@ const RepoCard = ({ repo, index, onSelect }: {
5690
bg-card border border-border hover:border-primary/40
5791
focus:outline-none focus:ring-2 focus:ring-primary/50 p-5 transition-colors"
5892
>
59-
{/* Mouse glow effect */}
6093
{hovering && (
6194
<div
6295
className="pointer-events-none absolute inset-0"
@@ -65,45 +98,84 @@ const RepoCard = ({ repo, index, onSelect }: {
6598
}}
6699
/>
67100
)}
68-
101+
69102
<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">
103+
{/* Top row: icon + status */}
104+
<div className="flex items-start justify-between mb-3">
105+
<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">
73106
<FolderGit2 className="w-5 h-5 text-primary" />
74107
</div>
75-
<StatusBadge status={repo.status} />
108+
<StatusDot status={repo.status} />
76109
</div>
77110

78-
{/* Title */}
79-
<h3 className="text-lg font-semibold text-foreground mb-0.5 group-hover:text-primary transition-colors">
111+
{/* Repo name + slug */}
112+
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate">
80113
{repo.name}
81114
</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()}
115+
{slug && (
116+
<p className="text-xs text-muted-foreground truncate mt-0.5">{slug}</p>
117+
)}
118+
119+
{/* Stats row */}
120+
<div className="flex items-center gap-3 mt-4 pt-3 border-t border-border text-xs text-muted-foreground">
121+
<span className="flex items-center gap-1">
122+
<Files className="w-3 h-3" />
123+
{(repo.file_count || 0).toLocaleString()}
124+
</span>
125+
{repo.function_count != null && repo.function_count > 0 && (
126+
<span className="flex items-center gap-1">
127+
<FunctionSquare className="w-3 h-3" />
128+
{repo.function_count.toLocaleString()}
90129
</span>
91-
</div>
130+
)}
131+
{indexed && (
132+
<span className="flex items-center gap-1 ml-auto">
133+
<Clock className="w-3 h-3" />
134+
{indexed}
135+
</span>
136+
)}
92137
</div>
93138
</div>
94139
</motion.button>
95140
)
96141
}
97142

143+
const SortTab = ({ label, active, onClick }: {
144+
label: string
145+
active: boolean
146+
onClick: () => void
147+
}) => (
148+
<button
149+
onClick={onClick}
150+
className={cn(
151+
'text-xs px-3 py-1 rounded-full transition-colors',
152+
active
153+
? 'bg-primary/10 text-primary font-medium'
154+
: 'text-muted-foreground hover:text-foreground',
155+
)}
156+
>
157+
{label}
158+
</button>
159+
)
160+
98161
export function RepoList({ repos, selectedRepo, onSelect, onAddClick, loading }: RepoListProps) {
99-
// Hooks must be called before any conditional returns
162+
const [sortMode, setSortMode] = useState<SortMode>('recent')
163+
100164
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])
165+
const sorted = [...repos]
166+
if (sortMode === 'recent') {
167+
sorted.sort((a, b) => {
168+
const aTime = a.last_indexed_at || a.created_at || ''
169+
const bTime = b.last_indexed_at || b.created_at || ''
170+
return bTime.localeCompare(aTime)
171+
})
172+
} else if (sortMode === 'name') {
173+
sorted.sort((a, b) => a.name.localeCompare(b.name))
174+
} else {
175+
sorted.sort((a, b) => (b.file_count || 0) - (a.file_count || 0))
176+
}
177+
return sorted
178+
}, [repos, sortMode])
107179

108180
if (loading) return <RepoGridSkeleton count={3} />
109181

@@ -133,15 +205,26 @@ export function RepoList({ repos, selectedRepo, onSelect, onAddClick, loading }:
133205
}
134206

135207
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-
))}
208+
<div className="space-y-4">
209+
{/* Sort bar */}
210+
<div className="flex items-center gap-1">
211+
<SortTab label="Recent" active={sortMode === 'recent'} onClick={() => setSortMode('recent')} />
212+
<SortTab label="Name" active={sortMode === 'name'} onClick={() => setSortMode('name')} />
213+
<SortTab label="Size" active={sortMode === 'size'} onClick={() => setSortMode('size')} />
214+
<span className="ml-auto text-xs text-muted-foreground">{repos.length} repos</span>
215+
</div>
216+
217+
{/* Grid */}
218+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
219+
{sortedRepos.map((repo, index) => (
220+
<RepoCard
221+
key={repo.id}
222+
repo={repo}
223+
index={index}
224+
onSelect={() => onSelect(repo.id)}
225+
/>
226+
))}
227+
</div>
145228
</div>
146229
)
147230
}

frontend/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ export interface Repository {
55
branch: string
66
status: string
77
file_count: number
8+
function_count?: number
9+
created_at?: string
10+
last_indexed_at?: string
811
}
912

1013
export interface SearchResult {

0 commit comments

Comments
 (0)