432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
import React, { useState } from 'react'
|
|
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
|
import { AppContainer, p as MyText } from 'web-components'
|
|
import { trpc } from '@/lib/trpc-client'
|
|
import { ErrorToast } from '@/services/toaster'
|
|
import { useManualRefresh } from '@/lib/refresh-context'
|
|
import { Image as ImageIcon, Plus, Pencil, Trash2, CircleCheck, CircleDot, Hourglass, X } from 'lucide-react'
|
|
import type { Banner as SharedBanner } from '@packages/shared'
|
|
|
|
export const Route = createFileRoute('/dashboard/banners')({
|
|
component: DashboardBanners,
|
|
})
|
|
|
|
// Serialized banner row (API JSON carries string dates) anchored to the shared shape
|
|
type Banner = Omit<SharedBanner, 'createdAt' | 'lastUpdated'> & {
|
|
createdAt: string,
|
|
lastUpdated: string
|
|
}
|
|
|
|
function DashboardBanners() {
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
|
|
// Edit mode state
|
|
const [editMode, setEditMode] = useState(false)
|
|
const [selectedSlot, setSelectedSlot] = useState<number | null>(null)
|
|
const [slotAssignments, setSlotAssignments] = useState<{ [slot: number]: number | null }>({})
|
|
const [originalAssignments, setOriginalAssignments] = useState<{ [slot: number]: number | null }>({})
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
// Real API calls
|
|
const { data: bannersData, isLoading, error, refetch } = trpc.admin.banner.getBanners.useQuery()
|
|
|
|
const deleteBannerMutation = trpc.admin.banner.deleteBanner.useMutation()
|
|
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation()
|
|
const emptySlotMutation = trpc.admin.banner.updateBanner.useMutation()
|
|
|
|
const banners = bannersData?.banners || []
|
|
|
|
// Initialize slot assignments when banners load
|
|
React.useEffect(() => {
|
|
const assignments: { [slot: number]: number | null } = { 1: null, 2: null, 3: null, 4: null }
|
|
banners.forEach((banner) => {
|
|
if (banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4) {
|
|
assignments[banner.serialNum] = banner.id
|
|
}
|
|
})
|
|
setSlotAssignments(assignments)
|
|
setOriginalAssignments({ ...assignments })
|
|
}, [bannersData])
|
|
|
|
const onRefresh = async () => {
|
|
setRefreshing(true)
|
|
await refetch()
|
|
setRefreshing(false)
|
|
}
|
|
|
|
useManualRefresh(() => {
|
|
onRefresh()
|
|
})
|
|
|
|
if (location.pathname !== '/dashboard/banners') {
|
|
return <Outlet />
|
|
}
|
|
|
|
// Slot and edit mode handlers
|
|
const handleSlotClick = (slotNumber: number) => {
|
|
setSelectedSlot(slotNumber)
|
|
setEditMode(true)
|
|
}
|
|
|
|
const handleBannerSelect = (bannerId: number) => {
|
|
if (!editMode || selectedSlot === null) return
|
|
|
|
// Remove banner from any existing slot
|
|
const newAssignments = { ...slotAssignments }
|
|
Object.keys(newAssignments).forEach((slot) => {
|
|
if (newAssignments[parseInt(slot)] === bannerId) {
|
|
newAssignments[parseInt(slot)] = null
|
|
}
|
|
})
|
|
|
|
// Assign banner to selected slot
|
|
newAssignments[selectedSlot] = bannerId
|
|
setSlotAssignments(newAssignments)
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
try {
|
|
// Get banners that need to be updated
|
|
const bannersToUpdate: { id: number, serialNum: number | null }[] = []
|
|
|
|
// Clear serial numbers for banners no longer in slots
|
|
banners.forEach((banner) => {
|
|
const currentSlot = banner.serialNum
|
|
const assignedSlot = Object.keys(slotAssignments).find((slot) =>
|
|
slotAssignments[parseInt(slot)] === banner.id
|
|
)
|
|
|
|
if (currentSlot !== (assignedSlot ? parseInt(assignedSlot) : null)) {
|
|
bannersToUpdate.push({
|
|
id: banner.id,
|
|
serialNum: assignedSlot ? parseInt(assignedSlot) : null
|
|
})
|
|
}
|
|
})
|
|
|
|
// Update banners that gained slots
|
|
Object.keys(slotAssignments).forEach((slot) => {
|
|
const slotNum = parseInt(slot)
|
|
const bannerId = slotAssignments[slotNum]
|
|
if (bannerId) {
|
|
const banner = banners.find((b) => b.id === bannerId)
|
|
if (banner && banner.serialNum !== slotNum) {
|
|
bannersToUpdate.push({
|
|
id: bannerId,
|
|
serialNum: slotNum
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// Execute updates
|
|
await Promise.all(
|
|
bannersToUpdate.map(({ id, serialNum }) =>
|
|
updateBannerMutation.mutateAsync({ id, serialNum })
|
|
)
|
|
)
|
|
|
|
setOriginalAssignments({ ...slotAssignments })
|
|
setEditMode(false)
|
|
setSelectedSlot(null)
|
|
await refetch()
|
|
window.alert('Success: Slot assignments saved successfully')
|
|
} catch (err) {
|
|
ErrorToast('Failed to save slot assignments')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleCancel = () => {
|
|
setSlotAssignments({ ...originalAssignments })
|
|
setEditMode(false)
|
|
setSelectedSlot(null)
|
|
}
|
|
|
|
const handleEmptySlot = async () => {
|
|
if (!selectedSlot || !slotAssignments[selectedSlot]) return
|
|
|
|
const bannerId = slotAssignments[selectedSlot]
|
|
const banner = banners.find((b) => b.id === bannerId)
|
|
|
|
if (!banner) return
|
|
|
|
try {
|
|
// Update banner's serialNum to null to empty the slot
|
|
await emptySlotMutation.mutateAsync({
|
|
id: banner.id,
|
|
serialNum: null
|
|
})
|
|
|
|
// Update local state
|
|
setSlotAssignments((prev) => ({
|
|
...prev,
|
|
[selectedSlot]: null
|
|
}))
|
|
|
|
// Update original assignments for cancel functionality
|
|
setOriginalAssignments((prev) => ({
|
|
...prev,
|
|
[selectedSlot]: null
|
|
}))
|
|
|
|
window.alert(`Success: Slot ${selectedSlot} has been emptied`)
|
|
} catch (err) {
|
|
ErrorToast('Failed to empty slot')
|
|
}
|
|
}
|
|
|
|
// Helper function to get banner name by ID
|
|
const getBannerNameById = (id: number | null) => {
|
|
if (!id) return null
|
|
const banner = banners.find((b) => b.id === id)
|
|
return banner ? banner.name : null
|
|
}
|
|
|
|
const handleEdit = (banner: Banner) => {
|
|
navigate({ to: '/dashboard/banners/$id/edit', params: { id: String(banner.id) } })
|
|
}
|
|
|
|
const handleDelete = (id: number) => {
|
|
if (!window.confirm('Delete Banner: Are you sure you want to delete this banner?')) return
|
|
;(async () => {
|
|
try {
|
|
await deleteBannerMutation.mutateAsync({ id })
|
|
refetch()
|
|
window.alert('Success: Banner deleted')
|
|
} catch (err) {
|
|
ErrorToast('Failed to delete banner')
|
|
}
|
|
})()
|
|
}
|
|
|
|
const handleCreate = () => {
|
|
navigate({ to: '/dashboard/banners/new' })
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
|
<MyText className="text-gray-600">Loading banners...</MyText>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
|
<MyText className="text-red-600">Error loading banners</MyText>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AppContainer>
|
|
<div className="relative flex-1 bg-gray-50">
|
|
<div className="max-h-[calc(100vh-160px)] flex-1 overflow-y-auto pb-24">
|
|
{/* Slots Row */}
|
|
<div className="mb-6 px-4">
|
|
<MyText className="mb-3 block text-sm font-medium text-gray-700">
|
|
{editMode ? `Select banner for Slot ${selectedSlot}` : 'Banner Slots'}
|
|
</MyText>
|
|
<div className="flex flex-row justify-between">
|
|
{[1, 2, 3, 4].map((slotNum) => {
|
|
const assignedBannerId = slotAssignments[slotNum]
|
|
const bannerName = getBannerNameById(assignedBannerId)
|
|
const isSelected = editMode && selectedSlot === slotNum
|
|
|
|
return (
|
|
<button
|
|
key={slotNum}
|
|
onClick={() => handleSlotClick(slotNum)}
|
|
className={`mx-1 flex-1 rounded-lg border-2 p-3 ${isSelected
|
|
? 'border-blue-500 bg-blue-50'
|
|
: assignedBannerId
|
|
? 'border-green-300 bg-green-50'
|
|
: 'border-gray-300 bg-white'
|
|
}`}
|
|
>
|
|
<span className="items-center">
|
|
<MyText className="mb-1 block text-xs text-gray-500">Slot {slotNum}</MyText>
|
|
<MyText
|
|
className={`block text-center text-xs font-medium ${assignedBannerId ? 'text-gray-900' : 'text-gray-400'
|
|
}`}
|
|
numberOfLines={2}
|
|
>
|
|
{bannerName || 'Empty'}
|
|
</MyText>
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Action buttons in edit mode */}
|
|
{editMode && (
|
|
<div className="mt-4 gap-3">
|
|
{/* Save/Cancel buttons */}
|
|
<div className="flex flex-row gap-3">
|
|
<button
|
|
onClick={handleCancel}
|
|
disabled={saving}
|
|
className={`flex-1 items-center rounded-lg bg-gray-500 px-4 py-3 ${saving ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<MyText className="font-semibold text-white">Cancel</MyText>
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className={`flex-1 items-center rounded-lg bg-blue-600 px-4 py-3 ${saving ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<MyText className="font-semibold text-white">
|
|
{saving ? 'Saving...' : 'Save Changes'}
|
|
</MyText>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Empty Slot button */}
|
|
<button
|
|
onClick={handleEmptySlot}
|
|
disabled={!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending}
|
|
className={`mt-3 w-full items-center rounded-lg bg-red-500 px-4 py-3 ${(!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending) ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<span className="flex flex-row items-center justify-center">
|
|
{emptySlotMutation.isPending ? (
|
|
<Hourglass size={16} color="white" />
|
|
) : (
|
|
<X size={16} color="white" />
|
|
)}
|
|
<MyText className="ml-2 font-semibold text-white">
|
|
{emptySlotMutation.isPending ? 'Emptying...' : 'Empty Slot'}
|
|
</MyText>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Banners List */}
|
|
<div className="px-4 pb-8">
|
|
{editMode && (
|
|
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
<MyText className="block text-center text-sm text-blue-800">
|
|
Tap a banner below to assign it to Slot {selectedSlot}
|
|
</MyText>
|
|
</div>
|
|
)}
|
|
|
|
{banners.length === 0 ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center py-8">
|
|
<span className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-slate-50">
|
|
<ImageIcon size={48} color="#94A3B8" />
|
|
</span>
|
|
<MyText className="block text-xl font-black tracking-tight text-slate-900">No Banners Yet</MyText>
|
|
<MyText className="mt-2 block px-8 text-center font-medium text-slate-500">
|
|
Start by creating your first banner using the button above.
|
|
</MyText>
|
|
</div>
|
|
) : (
|
|
banners.map((banner) => {
|
|
const isAssignedToSlot = Object.values(slotAssignments).includes(banner.id)
|
|
const isAssignedToSelectedSlot = slotAssignments[selectedSlot || 0] === banner.id
|
|
const canInteract = editMode
|
|
|
|
return (
|
|
<div
|
|
key={banner.id}
|
|
onClick={canInteract ? () => handleBannerSelect(banner.id) : undefined}
|
|
role={canInteract ? 'button' : undefined}
|
|
tabIndex={canInteract ? 0 : undefined}
|
|
onKeyDown={canInteract ? (e) => { if (e.key === 'Enter') handleBannerSelect(banner.id) } : undefined}
|
|
className={`mb-3 w-full rounded-xl border bg-white p-4 text-left shadow-sm ${canInteract ? 'cursor-pointer' : ''} ${canInteract && isAssignedToSelectedSlot
|
|
? 'border-blue-500 bg-blue-50'
|
|
: canInteract && isAssignedToSlot
|
|
? 'border-green-300 bg-green-50'
|
|
: canInteract
|
|
? 'border-gray-200'
|
|
: 'border-gray-100'
|
|
}`}
|
|
>
|
|
<span className="flex flex-row items-center">
|
|
<img
|
|
src={banner.imageUrl}
|
|
className="mr-3 h-16 w-16 rounded-lg object-cover"
|
|
alt={banner.name}
|
|
/>
|
|
<span className="flex-1">
|
|
<span className="mb-1 flex flex-row items-center justify-between">
|
|
<MyText className="font-semibold text-gray-900" numberOfLines={1}>
|
|
{banner.name}
|
|
</MyText>
|
|
<span className="flex flex-row items-center">
|
|
{canInteract && isAssignedToSelectedSlot && (
|
|
<span className="mr-2">
|
|
<CircleCheck size={16} color="#3B82F6" />
|
|
</span>
|
|
)}
|
|
{canInteract && isAssignedToSlot && !isAssignedToSelectedSlot && (
|
|
<span className="mr-2">
|
|
<CircleDot size={16} color="#10B981" />
|
|
</span>
|
|
)}
|
|
<span className={`mr-2 h-2 w-2 rounded-full ${banner.isActive ? 'bg-green-500' : 'bg-gray-400'
|
|
}`} />
|
|
{!editMode && (
|
|
<>
|
|
<button onClick={(e) => { e.stopPropagation(), handleEdit(banner as Banner) }} className="mr-1 p-1" aria-label="Edit banner">
|
|
<Pencil size={16} color="#64748B" />
|
|
</button>
|
|
<button onClick={(e) => { e.stopPropagation(), handleDelete(banner.id) }} className="p-1" aria-label="Delete banner">
|
|
<Trash2 size={16} color="#EF4444" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</span>
|
|
</span>
|
|
{banner.description && (
|
|
<MyText className="mt-1 block text-sm text-gray-500" numberOfLines={2}>
|
|
{banner.description}
|
|
</MyText>
|
|
)}
|
|
<span className="mt-2 flex flex-row items-center justify-between">
|
|
<MyText className="text-xs text-gray-400">
|
|
Created: {new Date(banner.createdAt).toLocaleDateString()}
|
|
</MyText>
|
|
{banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4 && (
|
|
<MyText className="text-xs font-medium text-blue-600">
|
|
Slot {banner.serialNum}
|
|
</MyText>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Floating Action Button */}
|
|
<button
|
|
onClick={handleCreate}
|
|
className="fixed bottom-6 right-6 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 shadow-lg"
|
|
aria-label="Add new banner"
|
|
>
|
|
<Plus size={24} color="white" />
|
|
</button>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|