Add mobile rolodex swipe card view
Replaces the card list on mobile with a swipeable rolodex. Current card is full-size, neighbors are visible at edges with 3D perspective scaling. Touch swipe or arrow buttons to navigate. Dot indicators for small sets, X/total counter for larger ones. Filters still work — rolodex resets via key changes when the store list updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1f760e0c65
commit
73a416d423
2 changed files with 190 additions and 5 deletions
|
|
@ -7,6 +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 { 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'
|
||||||
|
|
@ -185,11 +186,9 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: always cards */}
|
{/* Mobile: rolodex */}
|
||||||
<div className="sm:hidden grid grid-cols-1 gap-3">
|
<div className="sm:hidden">
|
||||||
{stores.map(s => (
|
<MobileRolodex stores={stores} onEdit={setEditStore} onDelete={setDeleteTarget} />
|
||||||
<StoreCard key={s.id} store={s} onEdit={setEditStore} onDelete={setDeleteTarget} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: toggle between table and grid */}
|
{/* Desktop: toggle between table and grid */}
|
||||||
|
|
|
||||||
186
src/components/MobileRolodex.tsx
Normal file
186
src/components/MobileRolodex.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { Pencil, Trash2, ChevronLeft, ChevronRight } 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileRolodex({ stores, onEdit, onDelete }: Props) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const [dragOffset, setDragOffset] = useState(0)
|
||||||
|
const touchStartX = useRef(0)
|
||||||
|
const touchStartY = useRef(0)
|
||||||
|
const isHorizontalDrag = useRef<boolean | null>(null)
|
||||||
|
|
||||||
|
const total = stores.length
|
||||||
|
|
||||||
|
function goTo(i: number) {
|
||||||
|
setIndex(Math.max(0, Math.min(total - 1, i)))
|
||||||
|
setDragOffset(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e: React.TouchEvent) {
|
||||||
|
touchStartX.current = e.touches[0].clientX
|
||||||
|
touchStartY.current = e.touches[0].clientY
|
||||||
|
isHorizontalDrag.current = null
|
||||||
|
setDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: React.TouchEvent) {
|
||||||
|
const dx = e.touches[0].clientX - touchStartX.current
|
||||||
|
const dy = e.touches[0].clientY - touchStartY.current
|
||||||
|
|
||||||
|
// Determine drag axis on first significant move
|
||||||
|
if (isHorizontalDrag.current === null && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
|
||||||
|
isHorizontalDrag.current = Math.abs(dx) > Math.abs(dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHorizontalDrag.current) {
|
||||||
|
e.preventDefault()
|
||||||
|
// Dampen drag at edges
|
||||||
|
const atStart = index === 0 && dx > 0
|
||||||
|
const atEnd = index === total - 1 && dx < 0
|
||||||
|
setDragOffset(atStart || atEnd ? dx * 0.2 : dx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
setDragging(false)
|
||||||
|
if (isHorizontalDrag.current && Math.abs(dragOffset) > 60) {
|
||||||
|
dragOffset < 0 ? goTo(index + 1) : goTo(index - 1)
|
||||||
|
} else {
|
||||||
|
setDragOffset(0)
|
||||||
|
}
|
||||||
|
isHorizontalDrag.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total === 0) return null
|
||||||
|
|
||||||
|
// Positions relative to current index
|
||||||
|
function getCardStyle(offset: number): React.CSSProperties {
|
||||||
|
const x = offset * 88 + dragOffset * (offset === 0 ? 1 : 0.15)
|
||||||
|
const scale = offset === 0 ? 1 : 0.88
|
||||||
|
const opacity = Math.abs(offset) > 1 ? 0 : offset === 0 ? 1 : 0.5
|
||||||
|
const zIndex = offset === 0 ? 10 : Math.abs(offset) === 1 ? 5 : 0
|
||||||
|
const rotateY = offset === 0 ? (dragOffset * -0.04) : (offset > 0 ? -6 : 6)
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: `translateX(${x}%) scale(${scale}) rotateY(${rotateY}deg)`,
|
||||||
|
opacity,
|
||||||
|
zIndex,
|
||||||
|
transition: dragging ? 'none' : 'all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Card stack */}
|
||||||
|
<div
|
||||||
|
className="relative h-[280px] perspective-[1200px]"
|
||||||
|
style={{ perspective: '1200px' }}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Render prev, current, next (and one more each side for depth) */}
|
||||||
|
{[-2, -1, 0, 1, 2].map(offset => {
|
||||||
|
const cardIndex = index + offset
|
||||||
|
if (cardIndex < 0 || cardIndex >= total) return null
|
||||||
|
const store = stores[cardIndex]
|
||||||
|
const storeTagList = parseTags(store.tags)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={store.id}
|
||||||
|
className="absolute inset-x-4 top-0 h-full glass rounded-2xl p-5 flex flex-col gap-3 origin-center"
|
||||||
|
style={{ ...getCardStyle(offset), transformStyle: 'preserve-3d' }}
|
||||||
|
>
|
||||||
|
{/* Card header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-base text-slate-100 leading-tight">{store.store}</p>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">{store.category}</p>
|
||||||
|
</div>
|
||||||
|
<RouteBadge route={store.route} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{storeTagList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{storeTagList.map(t => <TagChip key={t} tag={t} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between border-t border-white/5 pt-3">
|
||||||
|
<span className="text-xs text-slate-600">{formatDate(store.created_at)}</span>
|
||||||
|
{/* Actions only on current card */}
|
||||||
|
{offset === 0 && (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(store)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:text-blue-400 hover:bg-blue-500/10 border border-white/6 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={12} /> Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(store)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:text-red-400 hover:bg-red-500/10 border border-white/6 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center justify-between px-4">
|
||||||
|
<button
|
||||||
|
onClick={() => goTo(index - 1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-xl glass text-slate-400 hover:text-slate-200 disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dot indicators — show up to 9, scroll window for larger sets */}
|
||||||
|
<div className="flex items-center gap-1.5 overflow-hidden max-w-[160px]">
|
||||||
|
{total <= 9 ? (
|
||||||
|
Array.from({ length: total }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => goTo(i)}
|
||||||
|
className={`rounded-full transition-all ${
|
||||||
|
i === index
|
||||||
|
? 'w-5 h-2 bg-blue-400'
|
||||||
|
: 'w-2 h-2 bg-slate-700 hover:bg-slate-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium text-slate-400 tabular-nums">
|
||||||
|
{index + 1} <span className="text-slate-600">/ {total}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => goTo(index + 1)}
|
||||||
|
disabled={index === total - 1}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-xl glass text-slate-400 hover:text-slate-200 disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue