Replace rolodex with animated expandable list for mobile

Each store is a compact row with a route-colored left accent bar.
Tap to bloom open — smooth height animation, route-colored background
glow and border highlight, tags/meta/actions revealed. Rows stagger
in on load/filter change. No swipe gestures needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maddox 2026-04-07 20:35:50 -04:00
parent 623c3783b5
commit 57cb35d565
3 changed files with 180 additions and 3 deletions

View file

@ -7,7 +7,7 @@ import { StatsBar } from './components/StatsBar'
import { FilterBar } from './components/FilterBar' import { FilterBar } from './components/FilterBar'
import { StoreCard } from './components/StoreCard' import { StoreCard } from './components/StoreCard'
import { StoreTable } from './components/StoreTable' import { StoreTable } from './components/StoreTable'
import { MobileRolodex } from './components/MobileRolodex' import { MobileStoreList } from './components/MobileStoreList'
import { StoreModal } from './components/StoreModal' import { StoreModal } from './components/StoreModal'
import { DeleteConfirm } from './components/DeleteConfirm' import { DeleteConfirm } from './components/DeleteConfirm'
import { Pagination } from './components/Pagination' import { Pagination } from './components/Pagination'
@ -186,9 +186,9 @@ export default function App() {
</div> </div>
) : ( ) : (
<> <>
{/* Mobile: rolodex */} {/* Mobile: animated expandable list */}
<div className="sm:hidden"> <div className="sm:hidden">
<MobileRolodex stores={stores} onEdit={setEditStore} onDelete={setDeleteTarget} /> <MobileStoreList stores={stores} onEdit={setEditStore} onDelete={setDeleteTarget} />
</div> </div>
{/* Desktop: toggle between table and grid */} {/* Desktop: toggle between table and grid */}

View file

@ -0,0 +1,169 @@
import { useState, useRef, useEffect } from 'react'
import { Pencil, Trash2, ChevronDown, Tag, Calendar, Layers } from 'lucide-react'
import type { Store } from '../types'
import { RouteBadge } from './RouteBadge'
import { TagChip } from './TagChip'
import { parseTags, formatDate } from '../utils'
interface Props {
stores: Store[]
onEdit: (store: Store) => void
onDelete: (store: Store) => void
}
const ROUTE_GLOW: Record<string, string> = {
auto: 'rgba(34, 197, 94, 0.12)',
ask: 'rgba(245, 158, 11, 0.12)',
verify: 'rgba(59, 130, 246, 0.12)',
ignore: 'rgba(107, 114, 128, 0.08)',
}
const ROUTE_BORDER: Record<string, string> = {
auto: '#22c55e',
ask: '#f59e0b',
verify: '#3b82f6',
ignore: '#4b5563',
}
function StoreRow({
store,
index,
expanded,
onToggle,
onEdit,
onDelete,
}: {
store: Store
index: number
expanded: boolean
onToggle: () => void
onEdit: (s: Store) => void
onDelete: (s: Store) => void
}) {
const bodyRef = useRef<HTMLDivElement>(null)
const [bodyHeight, setBodyHeight] = useState(0)
useEffect(() => {
if (bodyRef.current) setBodyHeight(bodyRef.current.scrollHeight)
}, [store])
const tags = parseTags(store.tags)
const glow = ROUTE_GLOW[store.route] ?? ROUTE_GLOW.ignore
const border = ROUTE_BORDER[store.route] ?? ROUTE_BORDER.ignore
return (
<div
className="rounded-xl overflow-hidden transition-all duration-300 animate-row-in"
style={{
background: expanded ? glow : 'rgba(22, 27, 39, 0.6)',
border: `1px solid ${expanded ? border + '55' : 'rgba(255,255,255,0.06)'}`,
boxShadow: expanded ? `0 0 24px ${glow}` : 'none',
animationDelay: `${Math.min(index * 35, 350)}ms`,
}}
>
{/* Row header — always visible */}
<button
className="w-full flex items-center gap-3 px-4 py-3.5 text-left"
onClick={onToggle}
>
{/* Route accent bar */}
<div
className="w-1 self-stretch rounded-full shrink-0 transition-all duration-300"
style={{ background: border, opacity: expanded ? 1 : 0.5 }}
/>
{/* Name + category */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate leading-snug">
{store.store}
</p>
<p className="text-xs text-slate-500 mt-0.5">{store.category}</p>
</div>
<RouteBadge route={store.route} size="sm" />
<ChevronDown
size={15}
className="text-slate-500 shrink-0 transition-transform duration-300"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
</button>
{/* Expandable body */}
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: expanded ? bodyHeight + 'px' : '0px', opacity: expanded ? 1 : 0 }}
>
<div ref={bodyRef} className="px-4 pb-4 space-y-3">
{/* Divider */}
<div className="h-px" style={{ background: border + '30' }} />
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
<span className="flex items-center gap-1.5 text-xs text-slate-500">
<Layers size={11} className="text-slate-600" />
{store.category}
</span>
<span className="flex items-center gap-1.5 text-xs text-slate-500">
<Calendar size={11} className="text-slate-600" />
{formatDate(store.created_at)}
</span>
</div>
{/* Tags */}
{tags.length > 0 && (
<div className="flex items-start gap-2">
<Tag size={11} className="text-slate-600 mt-1 shrink-0" />
<div className="flex flex-wrap gap-1">
{tags.map(t => <TagChip key={t} tag={t} />)}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
onClick={e => { e.stopPropagation(); onEdit(store) }}
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-medium text-slate-400 hover:text-blue-400 border border-white/6 hover:border-blue-500/30 hover:bg-blue-500/8 transition-all"
>
<Pencil size={12} /> Edit
</button>
<button
onClick={e => { e.stopPropagation(); onDelete(store) }}
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-medium text-slate-400 hover:text-red-400 border border-white/6 hover:border-red-500/30 hover:bg-red-500/8 transition-all"
>
<Trash2 size={12} /> Delete
</button>
</div>
</div>
</div>
</div>
)
}
export function MobileStoreList({ stores, onEdit, onDelete }: Props) {
const [expandedId, setExpandedId] = useState<number | null>(null)
// Reset expanded when store list changes (filter/search)
useEffect(() => { setExpandedId(null) }, [stores])
function toggle(id: number) {
setExpandedId(prev => prev === id ? null : id)
}
return (
<div className="flex flex-col gap-1.5">
{stores.map((store, i) => (
<StoreRow
key={store.id}
store={store}
index={i}
expanded={expandedId === store.id}
onToggle={() => toggle(store.id)}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)
}

View file

@ -51,3 +51,11 @@ body { margin: 0; }
to { opacity: 1; transform: translateX(0); } to { opacity: 1; transform: translateX(0); }
} }
.animate-slide-in { animation: slide-in 0.2s ease-out; } .animate-slide-in { animation: slide-in 0.2s ease-out; }
@keyframes row-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-row-in {
animation: row-in 0.25s ease-out both;
}