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:
parent
623c3783b5
commit
57cb35d565
3 changed files with 180 additions and 3 deletions
|
|
@ -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 */}
|
||||||
|
|
|
||||||
169
src/components/MobileStoreList.tsx
Normal file
169
src/components/MobileStoreList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue