Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
341
resources/js/Pages/Studio/StudioArtworks.jsx
Normal file
341
resources/js/Pages/Studio/StudioArtworks.jsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import StudioToolbar from '../../Components/Studio/StudioToolbar'
|
||||
import StudioFilters from '../../Components/Studio/StudioFilters'
|
||||
import StudioGridCard from '../../Components/Studio/StudioGridCard'
|
||||
import StudioTable from '../../Components/Studio/StudioTable'
|
||||
import BulkActionsBar from '../../Components/Studio/BulkActionsBar'
|
||||
import BulkTagModal from '../../Components/Studio/BulkTagModal'
|
||||
import BulkCategoryModal from '../../Components/Studio/BulkCategoryModal'
|
||||
import ConfirmDangerModal from '../../Components/Studio/ConfirmDangerModal'
|
||||
|
||||
const VIEW_MODE_KEY = 'studio_view_mode'
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
export default function StudioArtworks() {
|
||||
const { props } = usePage()
|
||||
const { categories } = props
|
||||
|
||||
// State
|
||||
const [viewMode, setViewMode] = useState(() => localStorage.getItem(VIEW_MODE_KEY) || 'grid')
|
||||
const [artworks, setArtworks] = useState([])
|
||||
const [meta, setMeta] = useState({ current_page: 1, last_page: 1, per_page: 24, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sort, setSort] = useState('created_at:desc')
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const [filters, setFilters] = useState({ status: '', category: '', performance: '', date_from: '', date_to: '', tags: [] })
|
||||
const [selectedIds, setSelectedIds] = useState([])
|
||||
const [deleteModal, setDeleteModal] = useState({ open: false, ids: [] })
|
||||
const [tagModal, setTagModal] = useState({ open: false, mode: 'add' })
|
||||
const [categoryModal, setCategoryModal] = useState({ open: false })
|
||||
const searchTimer = useRef(null)
|
||||
|
||||
const perPage = viewMode === 'list' ? 50 : 24
|
||||
|
||||
// Fetch artworks from API
|
||||
const fetchArtworks = useCallback(async (page = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', page)
|
||||
params.set('per_page', perPage)
|
||||
params.set('sort', sort)
|
||||
if (search) params.set('q', search)
|
||||
if (filters.status) params.set('status', filters.status)
|
||||
if (filters.category) params.set('category', filters.category)
|
||||
if (filters.performance) params.set('performance', filters.performance)
|
||||
if (filters.date_from) params.set('date_from', filters.date_from)
|
||||
if (filters.date_to) params.set('date_to', filters.date_to)
|
||||
|
||||
const res = await fetch(`/api/studio/artworks?${params.toString()}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
setArtworks(data.data || [])
|
||||
setMeta(data.meta || meta)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch artworks:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, sort, filters, perPage])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => fetchArtworks(1), 300)
|
||||
return () => clearTimeout(searchTimer.current)
|
||||
}, [fetchArtworks])
|
||||
|
||||
// Persist view mode
|
||||
const handleViewModeChange = (mode) => {
|
||||
setViewMode(mode)
|
||||
localStorage.setItem(VIEW_MODE_KEY, mode)
|
||||
}
|
||||
|
||||
// Selection
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id])
|
||||
}
|
||||
const selectAll = () => {
|
||||
const allIds = artworks.map((a) => a.id)
|
||||
const allSelected = allIds.every((id) => selectedIds.includes(id))
|
||||
setSelectedIds(allSelected ? [] : allIds)
|
||||
}
|
||||
const clearSelection = () => setSelectedIds([])
|
||||
|
||||
// Actions
|
||||
const handleAction = async (action, artwork) => {
|
||||
if (action === 'edit') {
|
||||
window.location.href = `/studio/artworks/${artwork.id}/edit`
|
||||
return
|
||||
}
|
||||
if (action === 'delete') {
|
||||
setDeleteModal({ open: true, ids: [artwork.id] })
|
||||
return
|
||||
}
|
||||
// Toggle actions
|
||||
try {
|
||||
await fetch(`/api/studio/artworks/${artwork.id}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
fetchArtworks(meta.current_page)
|
||||
} catch (err) {
|
||||
console.error('Action failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk action execution
|
||||
const executeBulk = async (action) => {
|
||||
if (action === 'delete') {
|
||||
setDeleteModal({ open: true, ids: [...selectedIds] })
|
||||
return
|
||||
}
|
||||
if (action === 'add_tags') { setTagModal({ open: true, mode: 'add' }); return }
|
||||
if (action === 'remove_tags') { setTagModal({ open: true, mode: 'remove' }); return }
|
||||
if (action === 'change_category') { setCategoryModal({ open: true }); return }
|
||||
try {
|
||||
await fetch('/api/studio/artworks/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action, artwork_ids: selectedIds, params: {} }),
|
||||
})
|
||||
clearSelection()
|
||||
fetchArtworks(meta.current_page)
|
||||
} catch (err) {
|
||||
console.error('Bulk action failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm bulk tag action
|
||||
const confirmBulkTags = async (tagIds) => {
|
||||
const action = tagModal.mode === 'add' ? 'add_tags' : 'remove_tags'
|
||||
setTagModal({ open: false, mode: 'add' })
|
||||
try {
|
||||
await fetch('/api/studio/artworks/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action, artwork_ids: selectedIds, params: { tag_ids: tagIds } }),
|
||||
})
|
||||
clearSelection()
|
||||
fetchArtworks(meta.current_page)
|
||||
} catch (err) {
|
||||
console.error('Bulk tag action failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm bulk category change
|
||||
const confirmBulkCategory = async (categoryId) => {
|
||||
setCategoryModal({ open: false })
|
||||
try {
|
||||
await fetch('/api/studio/artworks/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action: 'change_category', artwork_ids: selectedIds, params: { category_id: categoryId } }),
|
||||
})
|
||||
clearSelection()
|
||||
fetchArtworks(meta.current_page)
|
||||
} catch (err) {
|
||||
console.error('Bulk category action failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm delete
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await fetch('/api/studio/artworks/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action: 'delete', artwork_ids: deleteModal.ids, confirm: 'DELETE' }),
|
||||
})
|
||||
setDeleteModal({ open: false, ids: [] })
|
||||
setSelectedIds((prev) => prev.filter((id) => !deleteModal.ids.includes(id)))
|
||||
fetchArtworks(meta.current_page)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title="Artworks">
|
||||
{/* Toolbar */}
|
||||
<StudioToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onFilterToggle={() => setFiltersOpen(!filtersOpen)}
|
||||
selectedCount={selectedIds.length}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* Filters sidebar (desktop) */}
|
||||
<div className="hidden lg:block">
|
||||
<StudioFilters
|
||||
open={filtersOpen}
|
||||
onClose={() => setFiltersOpen(false)}
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
categories={categories}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile filter drawer */}
|
||||
<div className="lg:hidden">
|
||||
<StudioFilters
|
||||
open={filtersOpen}
|
||||
onClose={() => setFiltersOpen(false)}
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
categories={categories}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid view */}
|
||||
{!loading && viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{artworks.map((art) => (
|
||||
<StudioGridCard
|
||||
key={art.id}
|
||||
artwork={art}
|
||||
selected={selectedIds.includes(art.id)}
|
||||
onSelect={toggleSelect}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List view */}
|
||||
{!loading && viewMode === 'list' && (
|
||||
<StudioTable
|
||||
artworks={artworks}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={toggleSelect}
|
||||
onSelectAll={selectAll}
|
||||
onAction={handleAction}
|
||||
onSort={setSort}
|
||||
currentSort={sort}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && artworks.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<i className="fa-solid fa-images text-4xl text-slate-600 mb-4" />
|
||||
<p className="text-slate-500 text-sm">No artworks match your criteria</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{meta.last_page > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6">
|
||||
{Array.from({ length: meta.last_page }, (_, i) => i + 1)
|
||||
.filter((p) => p === 1 || p === meta.last_page || Math.abs(p - meta.current_page) <= 2)
|
||||
.map((page, idx, arr) => (
|
||||
<React.Fragment key={page}>
|
||||
{idx > 0 && arr[idx - 1] !== page - 1 && (
|
||||
<span className="text-slate-600 text-sm">…</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fetchArtworks(page)}
|
||||
className={`w-9 h-9 rounded-xl text-sm font-medium transition-all ${
|
||||
page === meta.current_page
|
||||
? 'bg-accent text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total count */}
|
||||
{!loading && meta.total > 0 && (
|
||||
<p className="text-center text-xs text-slate-600 mt-3">
|
||||
{meta.total.toLocaleString()} artwork{meta.total !== 1 ? 's' : ''} total
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
<BulkActionsBar
|
||||
count={selectedIds.length}
|
||||
onExecute={executeBulk}
|
||||
onClearSelection={clearSelection}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<ConfirmDangerModal
|
||||
open={deleteModal.open}
|
||||
onClose={() => setDeleteModal({ open: false, ids: [] })}
|
||||
onConfirm={confirmDelete}
|
||||
title="Permanently delete artworks?"
|
||||
message={`This will permanently delete ${deleteModal.ids.length} artwork${deleteModal.ids.length !== 1 ? 's' : ''}. This action cannot be undone.`}
|
||||
/>
|
||||
|
||||
{/* Bulk tag modal */}
|
||||
<BulkTagModal
|
||||
open={tagModal.open}
|
||||
mode={tagModal.mode}
|
||||
onClose={() => setTagModal({ open: false, mode: 'add' })}
|
||||
onConfirm={confirmBulkTags}
|
||||
/>
|
||||
|
||||
{/* Bulk category modal */}
|
||||
<BulkCategoryModal
|
||||
open={categoryModal.open}
|
||||
categories={categories}
|
||||
onClose={() => setCategoryModal({ open: false })}
|
||||
onConfirm={confirmBulkCategory}
|
||||
/>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user