'use client' import { useState, useCallback, useMemo, useRef, useEffect, startTransition, type ChangeEvent } from 'react' import { useTranslations } from 'next-intl' import { UploadIcon, Trash2Icon, SearchIcon, GridIcon, ListIcon, XIcon, ChevronUpIcon, ImageIcon, LoaderIcon, } from 'lucide-react' import { Button } from '@nextsparkjs/core/components/ui/button' import { Input } from '@nextsparkjs/core/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@nextsparkjs/core/components/ui/select' import { Dialog, DialogContent, DialogTitle, DialogDescription, } from '@nextsparkjs/core/components/ui/dialog' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@nextsparkjs/core/components/ui/alert-dialog' import { MediaGrid } from '@nextsparkjs/core/components/media/MediaGrid' import { MediaList } from '@nextsparkjs/core/components/media/MediaList' import { MediaUploadZone } from '@nextsparkjs/core/components/media/MediaUploadZone' import { MediaDetailPanel } from '@nextsparkjs/core/components/media/MediaDetailPanel' import { MediaTagFilter } from '@nextsparkjs/core/components/media/MediaTagFilter' import { useMediaList, useDeleteMedia } from '@nextsparkjs/core/hooks/useMedia' import { useDebounce } from '@nextsparkjs/core/hooks/useDebounce' import { useToast } from '@nextsparkjs/core/hooks/useToast' import { sel } from '@nextsparkjs/core/lib/selectors' import { usePermissions } from '@nextsparkjs/core/lib/permissions/hooks' import { getTemplateOrDefaultClient } from '@nextsparkjs/registries/template-registry.client' import type { Media, MediaListOptions } from '@nextsparkjs/core/lib/media/types' import type { Permission } from '@nextsparkjs/core/lib/permissions/types' type ViewMode = 'grid' | 'list' const PAGE_SIZE = 40 function DefaultMediaDashboardPage() { const t = useTranslations('media') const { toast } = useToast() const deleteMutation = useDeleteMedia() const { canUpload, canUpdate, canDelete } = usePermissions({ canUpload: 'media.upload' as Permission, canUpdate: 'media.update' as Permission, canDelete: 'media.delete' as Permission, }) // State const [viewMode, setViewMode] = useState('grid') const [searchQuery, setSearchQuery] = useState('') const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all') const [sortBy, setSortBy] = useState('createdAt') const [sortDir, setSortDir] = useState('desc') const [selectedIds, setSelectedIds] = useState>(new Set()) const [editingMedia, setEditingMedia] = useState(null) const [deletingMedia, setDeletingMedia] = useState(null) const [page, setPage] = useState(0) const [selectedTagIds, setSelectedTagIds] = useState([]) const [showUpload, setShowUpload] = useState(false) const [columns, setColumns] = useState(6) const [isBulkDeleting, setIsBulkDeleting] = useState(false) const [bulkDeleteProgress, setBulkDeleteProgress] = useState({ current: 0, total: 0 }) const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const lastSelectedIndexRef = useRef(null) // Debounce search const debouncedSearch = useDebounce(searchQuery, 300) // Reset pagination when filters change useEffect(() => { setPage(0) }, [debouncedSearch, typeFilter, sortBy, sortDir, selectedTagIds]) // Fetch media const { data, isLoading, refetch } = useMediaList({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, orderBy: sortBy, orderDir: sortDir, type: typeFilter, search: debouncedSearch || undefined, tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined, }) const items = data?.data || [] const total = data?.total || 0 const totalPages = Math.ceil(total / PAGE_SIZE) const hasNextPage = page < totalPages - 1 const hasPrevPage = page > 0 // Memoize sort options to prevent array recreation const sortOptions = useMemo(() => [ { value: 'createdAt:desc', label: t('toolbar.sort.newest'), orderBy: 'createdAt' as const, orderDir: 'desc' as const }, { value: 'createdAt:asc', label: t('toolbar.sort.oldest'), orderBy: 'createdAt' as const, orderDir: 'asc' as const }, { value: 'filename:asc', label: t('toolbar.sort.nameAsc'), orderBy: 'filename' as const, orderDir: 'asc' as const }, { value: 'filename:desc', label: t('toolbar.sort.nameDesc'), orderBy: 'filename' as const, orderDir: 'desc' as const }, { value: 'fileSize:desc', label: t('toolbar.sort.sizeDesc'), orderBy: 'fileSize' as const, orderDir: 'desc' as const }, { value: 'fileSize:asc', label: t('toolbar.sort.sizeAsc'), orderBy: 'fileSize' as const, orderDir: 'asc' as const }, ], [t]) const currentSortValue = `${sortBy}:${sortDir}` // Stable callbacks with useCallback + functional setState const handleSortChange = useCallback((value: string) => { const option = sortOptions.find(opt => opt.value === value) if (option) { startTransition(() => { setSortBy(option.orderBy) setSortDir(option.orderDir) }) } }, [sortOptions]) const handleSearchChange = useCallback((e: ChangeEvent) => { setSearchQuery(e.target.value) }, []) const handleTypeFilterChange = useCallback((value: string) => { startTransition(() => { setTypeFilter(value as 'all' | 'image' | 'video') }) }, []) const handleTagsChange = useCallback((tagIds: string[]) => { startTransition(() => { setSelectedTagIds(tagIds) }) }, []) const handleSelect = useCallback((media: Media, options?: { shiftKey?: boolean }) => { const currentIndex = items.findIndex(m => m.id === media.id) if (currentIndex === -1) return const lastIndex = lastSelectedIndexRef.current if (options?.shiftKey && lastIndex !== null && lastIndex >= 0 && lastIndex < items.length) { // Range selection: select all items between last selected and current const start = Math.min(lastIndex, currentIndex) const end = Math.max(lastIndex, currentIndex) setSelectedIds(prev => { const newSet = new Set(prev) for (let i = start; i <= end; i++) { newSet.add(items[i].id) } return newSet }) } else { // Toggle single selection setSelectedIds((prev) => { const newSet = new Set(prev) if (newSet.has(media.id)) { newSet.delete(media.id) } else { newSet.add(media.id) } return newSet }) } lastSelectedIndexRef.current = currentIndex }, [items]) const handleDelete = useCallback(async () => { if (!deletingMedia) return try { await deleteMutation.mutateAsync(deletingMedia.id) toast({ title: t('delete.success') }) setDeletingMedia(null) setSelectedIds((prev) => { const newSet = new Set(prev) newSet.delete(deletingMedia.id) return newSet }) refetch() } catch (error) { toast({ title: t('delete.error'), description: error instanceof Error ? error.message : t('delete.error'), variant: 'destructive', }) } }, [deletingMedia, deleteMutation, toast, t, refetch]) const handleBulkDelete = useCallback(async () => { if (selectedIds.size === 0) return setShowBulkDeleteConfirm(false) setIsBulkDeleting(true) const idsArray = Array.from(selectedIds) const totalCount = idsArray.length setBulkDeleteProgress({ current: 0, total: totalCount }) let successCount = 0 for (let i = 0; i < idsArray.length; i++) { setBulkDeleteProgress({ current: i + 1, total: totalCount }) try { await deleteMutation.mutateAsync(idsArray[i]) successCount++ } catch { /* continue */ } } setIsBulkDeleting(false) setBulkDeleteProgress({ current: 0, total: 0 }) if (successCount > 0) { toast({ title: t('delete.success'), description: t('dashboard.bulkDeleteSuccess', { count: successCount }), }) setSelectedIds(new Set()) lastSelectedIndexRef.current = null refetch() } }, [selectedIds, deleteMutation, toast, t, refetch]) const handleUploadComplete = useCallback((uploadedMedia: Media[]) => { setShowUpload(false) refetch() // Auto-open edit panel for single file uploads if (uploadedMedia.length === 1) { setEditingMedia(uploadedMedia[0]) } }, [refetch]) const handleToggleUpload = useCallback(() => { setShowUpload(prev => !prev) }, []) const handleCloseDetail = useCallback(() => { setEditingMedia(null) }, []) const handleCloseDeletingMedia = useCallback((open: boolean) => { if (!open) setDeletingMedia(null) }, []) const handlePrevPage = useCallback(() => { setPage(prev => prev - 1) }, []) const handleNextPage = useCallback(() => { setPage(prev => prev + 1) }, []) const handleColumnsChange = useCallback((v: string) => { setColumns(Number(v)) }, []) const selectedCount = selectedIds.size return (
{/* Row 1: Title + Actions */}

{t('title')}

{t('dashboard.subtitle', { count: total })}

{canUpload && ( )}
{/* Upload zone (collapsible) */} {showUpload && canUpload && ( )} {/* Row 2: Search + Filters + Sort + View toggle */}
{/* Search */}
{/* Type filter */} {/* Tag filter */} {/* Sort */} {/* Column selector + View toggle */}
{viewMode === 'grid' && ( )}
{/* Row 3: Content */}
{viewMode === 'grid' ? ( ()} onSelect={canDelete ? handleSelect : undefined} onEdit={isBulkDeleting ? undefined : setEditingMedia} onDelete={isBulkDeleting || !canDelete ? undefined : setDeletingMedia} mode={canDelete ? 'multiple' : 'single'} readOnly={!canUpdate} columns={columns} /> ) : ( ()} onSelect={canDelete ? handleSelect : undefined} onEdit={isBulkDeleting ? undefined : setEditingMedia} onDelete={isBulkDeleting || !canDelete ? undefined : setDeletingMedia} mode={canDelete ? 'multiple' : 'single'} readOnly={!canUpdate} /> )}
{/* Pagination */} {totalPages > 1 && (

{t('dashboard.pagination', { from: page * PAGE_SIZE + 1, to: Math.min((page + 1) * PAGE_SIZE, total), total, })}

)} {/* Detail Dialog (centered modal) */} !open && setEditingMedia(null)}>
{/* Left: Image preview - Dark canvas */}
{editingMedia?.mimeType.startsWith('image/') ? ( {editingMedia.alt ) : ( )}
{/* Right: Details panel */}
{t('detail.title')} {editingMedia?.filename}
{/* Single delete confirmation dialog */} {t('delete.title')} {t('delete.message')} {t('delete.cancel')} {t('delete.confirm')} {/* Bulk delete confirmation dialog */} !open && setShowBulkDeleteConfirm(false)} > {t('dashboard.bulkDeleteConfirm', { count: selectedCount })} {t('dashboard.bulkDeleteConfirmDesc')} {t('delete.cancel')} {t('dashboard.bulkDelete')} {/* Floating action bar */} {canDelete && (selectedCount > 0 || isBulkDeleting) && (
{isBulkDeleting ? ( <> {t('dashboard.bulkDeleting', { current: bulkDeleteProgress.current, total: bulkDeleteProgress.total, })} ) : ( <> {t('dashboard.selected', { count: selectedCount })}
)}
)}
) } // Export the resolved component (theme override or default) export default getTemplateOrDefaultClient('app/dashboard/(main)/media/page.tsx', DefaultMediaDashboardPage)