Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
27
resources/js/components/Badges/RisingBadge.jsx
Normal file
27
resources/js/components/Badges/RisingBadge.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function RisingBadge({ heatScore, rankingScore }) {
|
||||
if (!heatScore && !rankingScore) return null
|
||||
|
||||
const isRising = heatScore > 5
|
||||
const isTrending = rankingScore > 50
|
||||
|
||||
if (!isRising && !isTrending) return null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{isRising && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||
<i className="fa-solid fa-fire text-[10px]" />
|
||||
Rising
|
||||
</span>
|
||||
)}
|
||||
{isTrending && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30">
|
||||
<i className="fa-solid fa-arrow-trend-up text-[10px]" />
|
||||
Trending
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
18
resources/js/components/Badges/StatusBadge.jsx
Normal file
18
resources/js/components/Badges/StatusBadge.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
const statusConfig = {
|
||||
published: { label: 'Published', className: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
|
||||
draft: { label: 'Draft', className: 'bg-amber-500/20 text-amber-400 border-amber-500/30' },
|
||||
archived: { label: 'Archived', className: 'bg-slate-500/20 text-slate-400 border-slate-500/30' },
|
||||
scheduled: { label: 'Scheduled', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
const config = statusConfig[status] || statusConfig.draft
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border ${config.className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
77
resources/js/components/Studio/BulkActionsBar.jsx
Normal file
77
resources/js/components/Studio/BulkActionsBar.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const actions = [
|
||||
{ value: 'publish', label: 'Publish', icon: 'fa-eye', danger: false },
|
||||
{ value: 'unpublish', label: 'Unpublish (draft)', icon: 'fa-eye-slash', danger: false },
|
||||
{ value: 'archive', label: 'Archive', icon: 'fa-box-archive', danger: false },
|
||||
{ value: 'unarchive', label: 'Unarchive', icon: 'fa-rotate-left', danger: false },
|
||||
{ value: 'delete', label: 'Delete', icon: 'fa-trash', danger: true },
|
||||
{ value: 'change_category', label: 'Change category', icon: 'fa-folder', danger: false },
|
||||
{ value: 'add_tags', label: 'Add tags', icon: 'fa-tag', danger: false },
|
||||
{ value: 'remove_tags', label: 'Remove tags', icon: 'fa-tags', danger: false },
|
||||
]
|
||||
|
||||
export default function BulkActionsBar({ count, onExecute, onClearSelection }) {
|
||||
const [action, setAction] = useState('')
|
||||
|
||||
if (count === 0) return null
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!action) return
|
||||
onExecute(action)
|
||||
setAction('')
|
||||
}
|
||||
|
||||
const selectedAction = actions.find((a) => a.value === action)
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-nova-900/95 backdrop-blur-xl border-t border-white/10 px-4 py-3 shadow-xl shadow-black/20">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-accent/20 text-accent text-sm font-bold">
|
||||
{count}
|
||||
</span>
|
||||
<span className="text-sm text-slate-300">
|
||||
{count === 1 ? 'artwork' : 'artworks'} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 min-w-[180px]"
|
||||
>
|
||||
<option value="" className="bg-nova-900">Choose action…</option>
|
||||
{actions.map((a) => (
|
||||
<option key={a.value} value={a.value} className="bg-nova-900">
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!action}
|
||||
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
|
||||
action
|
||||
? selectedAction?.danger
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-accent hover:bg-accent/90 text-white'
|
||||
: 'bg-white/5 text-slate-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClearSelection}
|
||||
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
resources/js/components/Studio/BulkCategoryModal.jsx
Normal file
96
resources/js/components/Studio/BulkCategoryModal.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Modal for choosing a category in bulk.
|
||||
*
|
||||
* Props:
|
||||
* - open: boolean
|
||||
* - categories: array of content types with nested categories
|
||||
* - onClose: () => void
|
||||
* - onConfirm: (categoryId: number) => void
|
||||
*/
|
||||
export default function BulkCategoryModal({ open, categories = [], onClose, onConfirm }) {
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelectedId('')
|
||||
}, [open])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedId) return
|
||||
onConfirm(Number(selectedId))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'Enter' && selectedId) handleConfirm()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md bg-nova-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<i className="fa-solid fa-folder text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">Change category</h3>
|
||||
<p className="text-sm text-slate-400">Choose a category to assign to the selected artworks.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category select */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
|
||||
<select
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
<option value="" className="bg-nova-900">Select a category…</option>
|
||||
{categories.map((ct) => (
|
||||
<optgroup key={ct.id} label={ct.name}>
|
||||
{ct.categories?.map((cat) => (
|
||||
<React.Fragment key={cat.id}>
|
||||
<option value={cat.id} className="bg-nova-900">{cat.name}</option>
|
||||
{cat.children?.map((ch) => (
|
||||
<option key={ch.id} value={ch.id} className="bg-nova-900"> {ch.name}</option>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId}
|
||||
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
|
||||
selectedId
|
||||
? 'bg-accent hover:bg-accent/90 text-white'
|
||||
: 'bg-white/5 text-slate-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Apply category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
resources/js/components/Studio/BulkTagModal.jsx
Normal file
208
resources/js/components/Studio/BulkTagModal.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Modal for picking tags to add/remove in bulk.
|
||||
*
|
||||
* Props:
|
||||
* - open: boolean
|
||||
* - mode: 'add' | 'remove'
|
||||
* - onClose: () => void
|
||||
* - onConfirm: (tagIds: number[]) => void
|
||||
*/
|
||||
export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [selected, setSelected] = useState([]) // { id, name }
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const searchTimer = useRef(null)
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setSelected([])
|
||||
setTimeout(() => inputRef.current?.focus(), 100)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Debounced tag search
|
||||
const searchTags = useCallback(async (q) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
const res = await fetch(`/api/studio/tags/search?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
setResults(data || [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => searchTags(query), 250)
|
||||
return () => clearTimeout(searchTimer.current)
|
||||
}, [query, open, searchTags])
|
||||
|
||||
const toggleTag = (tag) => {
|
||||
setSelected((prev) => {
|
||||
const exists = prev.find((t) => t.id === tag.id)
|
||||
return exists ? prev.filter((t) => t.id !== tag.id) : [...prev, { id: tag.id, name: tag.name }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeSelected = (id) => {
|
||||
setSelected((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected.length === 0) return
|
||||
onConfirm(selected.map((t) => t.id))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isAdd = mode === 'add'
|
||||
const title = isAdd ? 'Add tags' : 'Remove tags'
|
||||
const accentColor = isAdd ? 'accent' : 'amber-500'
|
||||
const icon = isAdd ? 'fa-tag' : 'fa-tags'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg bg-nova-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full ${isAdd ? 'bg-accent/20' : 'bg-amber-500/20'} flex items-center justify-center flex-shrink-0`}>
|
||||
<i className={`fa-solid ${icon} ${isAdd ? 'text-accent' : 'text-amber-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">{title}</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
{isAdd ? 'Search and select tags to add to the selected artworks.' : 'Search and select tags to remove from the selected artworks.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
placeholder="Search tags…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected tags chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium ${
|
||||
isAdd ? 'bg-accent/20 text-accent' : 'bg-amber-500/20 text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => removeSelected(tag.id)}
|
||||
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-[10px]" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-500 py-4">
|
||||
{query ? 'No tags found' : 'Type to search tags'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
results.map((tag) => {
|
||||
const isSelected = selected.some((t) => t.id === tag.id)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
|
||||
isSelected
|
||||
? isAdd
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'bg-amber-500/10 text-amber-300'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<i
|
||||
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
|
||||
isSelected ? (isAdd ? 'text-accent' : 'text-amber-400') : 'text-slate-500'
|
||||
}`}
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={selected.length === 0}
|
||||
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
|
||||
selected.length > 0
|
||||
? isAdd
|
||||
? 'bg-accent hover:bg-accent/90 text-white'
|
||||
: 'bg-amber-600 hover:bg-amber-700 text-white'
|
||||
: 'bg-white/5 text-slate-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isAdd ? 'Add' : 'Remove'} {selected.length > 0 ? `${selected.length} tag${selected.length !== 1 ? 's' : ''}` : 'tags'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
resources/js/components/Studio/ConfirmDangerModal.jsx
Normal file
76
resources/js/components/Studio/ConfirmDangerModal.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
|
||||
export default function ConfirmDangerModal({ open, onClose, onConfirm, title, message, confirmText = 'DELETE' }) {
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInput('')
|
||||
setTimeout(() => inputRef.current?.focus(), 100)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const canConfirm = input === confirmText
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'Enter' && canConfirm) onConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md bg-nova-900 border border-red-500/30 rounded-2xl shadow-2xl shadow-red-500/10 p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<i className="fa-solid fa-triangle-exclamation text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">{title}</h3>
|
||||
<p className="text-sm text-slate-400 mt-1">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">
|
||||
Type <span className="text-red-400 font-mono">{confirmText}</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-red-500/50 font-mono"
|
||||
placeholder={confirmText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!canConfirm}
|
||||
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
|
||||
canConfirm
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-white/5 text-slate-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Delete permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
resources/js/components/Studio/StudioFilters.jsx
Normal file
127
resources/js/components/Studio/StudioFilters.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react'
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'All statuses' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
]
|
||||
|
||||
const performanceOptions = [
|
||||
{ value: '', label: 'All performance' },
|
||||
{ value: 'rising', label: 'Rising (hot)' },
|
||||
{ value: 'top', label: 'Top performers' },
|
||||
{ value: 'low', label: 'Low performers' },
|
||||
]
|
||||
|
||||
export default function StudioFilters({
|
||||
open,
|
||||
onClose,
|
||||
filters,
|
||||
onFilterChange,
|
||||
categories = [],
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
onFilterChange({ ...filters, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
<div className="lg:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Filter panel */}
|
||||
<div className="fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 lg:w-64 bg-nova-900 lg:bg-nova-900/40 border-r lg:border border-white/10 lg:rounded-2xl p-5 space-y-5 overflow-y-auto lg:static lg:mb-4">
|
||||
<div className="flex items-center justify-between lg:hidden">
|
||||
<h3 className="text-base font-semibold text-white">Filters</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white" aria-label="Close filters">
|
||||
<i className="fa-solid fa-xmark text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="hidden lg:block text-sm font-semibold text-slate-400 uppercase tracking-wider">Filters</h3>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Status</label>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
{statusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
|
||||
<select
|
||||
value={filters.category || ''}
|
||||
onChange={(e) => handleChange('category', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
<option value="" className="bg-nova-900">All categories</option>
|
||||
{categories.map((ct) => (
|
||||
<optgroup key={ct.id} label={ct.name}>
|
||||
{ct.categories?.map((cat) => (
|
||||
<React.Fragment key={cat.id}>
|
||||
<option value={cat.slug} className="bg-nova-900">{cat.name}</option>
|
||||
{cat.children?.map((ch) => (
|
||||
<option key={ch.id} value={ch.slug} className="bg-nova-900"> {ch.name}</option>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Performance */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Performance</label>
|
||||
<select
|
||||
value={filters.performance || ''}
|
||||
onChange={(e) => handleChange('performance', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
{performanceOptions.map((o) => (
|
||||
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Date range</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_from || ''}
|
||||
onChange={(e) => handleChange('date_from', e.target.value)}
|
||||
className="px-2 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_to || ''}
|
||||
onChange={(e) => handleChange('date_to', e.target.value)}
|
||||
className="px-2 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear */}
|
||||
<button
|
||||
onClick={() => onFilterChange({ status: '', category: '', performance: '', date_from: '', date_to: '', tags: [] })}
|
||||
className="w-full text-center text-xs text-slate-500 hover:text-white transition-colors py-2"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
101
resources/js/components/Studio/StudioGridCard.jsx
Normal file
101
resources/js/components/Studio/StudioGridCard.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react'
|
||||
import StatusBadge from '../Badges/StatusBadge'
|
||||
import RisingBadge from '../Badges/RisingBadge'
|
||||
|
||||
function getStatus(art) {
|
||||
if (art.deleted_at) return 'archived'
|
||||
if (!art.is_public) return 'draft'
|
||||
return 'published'
|
||||
}
|
||||
|
||||
function statItem(icon, value) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<span>{icon}</span>
|
||||
<span>{typeof value === 'number' ? value.toLocaleString() : value}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioGridCard({ artwork, selected, onSelect, onAction }) {
|
||||
const status = getStatus(artwork)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative bg-nova-900/60 border rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-accent/5 ${
|
||||
selected ? 'border-accent/60 ring-2 ring-accent/20' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
<label className="absolute top-3 left-3 z-10 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => onSelect(artwork.id)}
|
||||
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-[4/3] bg-nova-800 overflow-hidden">
|
||||
<img
|
||||
src={artwork.thumb_url}
|
||||
alt={artwork.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Hover actions */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute bottom-3 right-3 flex gap-1.5">
|
||||
<ActionBtn icon="fa-eye" title="View public" onClick={() => window.open(`/artworks/${artwork.slug}`, '_blank')} />
|
||||
<ActionBtn icon="fa-pen" title="Edit" onClick={() => onAction('edit', artwork)} />
|
||||
{status !== 'archived' ? (
|
||||
<ActionBtn icon="fa-box-archive" title="Archive" onClick={() => onAction('archive', artwork)} />
|
||||
) : (
|
||||
<ActionBtn icon="fa-rotate-left" title="Unarchive" onClick={() => onAction('unarchive', artwork)} />
|
||||
)}
|
||||
<ActionBtn icon="fa-trash" title="Delete" onClick={() => onAction('delete', artwork)} danger />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-white truncate" title={artwork.title}>
|
||||
{artwork.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge status={status} />
|
||||
<RisingBadge heatScore={artwork.heat_score} rankingScore={artwork.ranking_score} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{statItem('👁', artwork.views)}
|
||||
{statItem('❤️', artwork.favourites)}
|
||||
{statItem('🔗', artwork.shares)}
|
||||
{statItem('💬', artwork.comments)}
|
||||
{statItem('⬇', artwork.downloads)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionBtn({ icon, title, onClick, danger }) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
title={title}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm transition-all backdrop-blur-sm ${
|
||||
danger
|
||||
? 'bg-red-500/20 text-red-400 hover:bg-red-500/40'
|
||||
: 'bg-white/10 text-white hover:bg-white/20'
|
||||
}`}
|
||||
aria-label={title}
|
||||
>
|
||||
<i className={`fa-solid ${icon}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
144
resources/js/components/Studio/StudioTable.jsx
Normal file
144
resources/js/components/Studio/StudioTable.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import StatusBadge from '../Badges/StatusBadge'
|
||||
import RisingBadge from '../Badges/RisingBadge'
|
||||
|
||||
function getStatus(art) {
|
||||
if (art.deleted_at) return 'archived'
|
||||
if (!art.is_public) return 'draft'
|
||||
return 'published'
|
||||
}
|
||||
|
||||
export default function StudioTable({ artworks, selectedIds, onSelect, onSelectAll, onAction, onSort, currentSort }) {
|
||||
const allSelected = artworks.length > 0 && artworks.every((a) => selectedIds.includes(a.id))
|
||||
|
||||
const columns = [
|
||||
{ key: 'title', label: 'Title', sortable: false },
|
||||
{ key: 'status', label: 'Status', sortable: false },
|
||||
{ key: 'category', label: 'Category', sortable: false },
|
||||
{ key: 'created_at', label: 'Created', sortable: true, sort: 'created_at' },
|
||||
{ key: 'views', label: 'Views', sortable: true, sort: 'views' },
|
||||
{ key: 'favourites', label: 'Favs', sortable: true, sort: 'favorites_count' },
|
||||
{ key: 'shares', label: 'Shares', sortable: true, sort: 'shares_count' },
|
||||
{ key: 'comments', label: 'Comments', sortable: true, sort: 'comments_count' },
|
||||
{ key: 'downloads', label: 'Downloads', sortable: true, sort: 'downloads' },
|
||||
{ key: 'ranking_score', label: 'Rank', sortable: true, sort: 'ranking_score' },
|
||||
{ key: 'heat_score', label: 'Heat', sortable: true, sort: 'heat_score' },
|
||||
]
|
||||
|
||||
const handleSort = (col) => {
|
||||
if (!col.sortable) return
|
||||
const field = col.sort
|
||||
const [currentField, currentDir] = (currentSort || '').split(':')
|
||||
const dir = currentField === field && currentDir === 'desc' ? 'asc' : 'desc'
|
||||
onSort(`${field}:${dir}`)
|
||||
}
|
||||
|
||||
const getSortIcon = (col) => {
|
||||
if (!col.sortable) return null
|
||||
const [currentField, currentDir] = (currentSort || '').split(':')
|
||||
if (currentField !== col.sort) return <i className="fa-solid fa-sort text-slate-600 ml-1 text-[10px]" />
|
||||
return <i className={`fa-solid fa-sort-${currentDir === 'asc' ? 'up' : 'down'} text-accent ml-1 text-[10px]`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-white/10 bg-nova-900/40">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="sticky top-0 z-10 bg-nova-900/90 backdrop-blur-sm border-b border-white/10">
|
||||
<tr>
|
||||
<th className="p-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={onSelectAll}
|
||||
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3 w-12"></th>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`p-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap ${col.sortable ? 'cursor-pointer hover:text-white select-none' : ''}`}
|
||||
onClick={() => handleSort(col)}
|
||||
>
|
||||
{col.label}
|
||||
{getSortIcon(col)}
|
||||
</th>
|
||||
))}
|
||||
<th className="p-3 w-20 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{artworks.map((art) => (
|
||||
<tr
|
||||
key={art.id}
|
||||
className={`transition-colors ${selectedIds.includes(art.id) ? 'bg-accent/5' : 'hover:bg-white/[0.02]'}`}
|
||||
>
|
||||
<td className="p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(art.id)}
|
||||
onChange={() => onSelect(art.id)}
|
||||
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<img
|
||||
src={art.thumb_url}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-lg object-cover bg-nova-800"
|
||||
loading="lazy"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className="text-white font-medium truncate block max-w-[200px]" title={art.title}>{art.title}</span>
|
||||
</td>
|
||||
<td className="p-3"><StatusBadge status={getStatus(art)} /></td>
|
||||
<td className="p-3 text-slate-400">{art.category || '—'}</td>
|
||||
<td className="p-3 text-slate-400 whitespace-nowrap">{art.created_at ? new Date(art.created_at).toLocaleDateString() : '—'}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.views.toLocaleString()}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.favourites.toLocaleString()}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.shares.toLocaleString()}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.comments.toLocaleString()}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.downloads.toLocaleString()}</td>
|
||||
<td className="p-3">
|
||||
<RisingBadge heatScore={0} rankingScore={art.ranking_score} />
|
||||
<span className="text-slate-400 text-xs">{art.ranking_score.toFixed(1)}</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<RisingBadge heatScore={art.heat_score} rankingScore={0} />
|
||||
<span className="text-slate-400 text-xs">{art.heat_score.toFixed(1)}</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onAction('edit', art)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs text-slate-400 hover:text-white hover:bg-white/10 transition-all"
|
||||
title="Edit"
|
||||
aria-label={`Edit ${art.title}`}
|
||||
>
|
||||
<i className="fa-solid fa-pen" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction('delete', art)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-all"
|
||||
title="Delete"
|
||||
aria-label={`Delete ${art.title}`}
|
||||
>
|
||||
<i className="fa-solid fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{artworks.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={14} className="p-12 text-center text-slate-500">
|
||||
No artworks found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
resources/js/components/Studio/StudioToolbar.jsx
Normal file
90
resources/js/components/Studio/StudioToolbar.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'created_at:desc', label: 'Latest' },
|
||||
{ value: 'ranking_score:desc', label: 'Trending' },
|
||||
{ value: 'heat_score:desc', label: 'Rising' },
|
||||
{ value: 'views:desc', label: 'Most viewed' },
|
||||
{ value: 'favorites_count:desc', label: 'Most favourited' },
|
||||
{ value: 'shares_count:desc', label: 'Most shared' },
|
||||
{ value: 'downloads:desc', label: 'Most downloaded' },
|
||||
]
|
||||
|
||||
export default function StudioToolbar({
|
||||
search,
|
||||
onSearchChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onFilterToggle,
|
||||
selectedCount,
|
||||
onUpload,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search title or tags…"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
className="w-full pr-4 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => onSortChange(e.target.value)}
|
||||
className="px-3 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none cursor-pointer min-w-[160px]"
|
||||
>
|
||||
{sortOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} className="bg-nova-900 text-white">
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={onFilterToggle}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<i className="fa-solid fa-filter" />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
</button>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center bg-nova-900/60 border border-white/10 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className={`px-3 py-2.5 text-sm transition-all ${viewMode === 'grid' ? 'bg-accent/20 text-accent' : 'text-slate-400 hover:text-white'}`}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<i className="fa-solid fa-table-cells" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={`px-3 py-2.5 text-sm transition-all ${viewMode === 'list' ? 'bg-accent/20 text-accent' : 'text-slate-400 hover:text-white'}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
<i className="fa-solid fa-list" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<a
|
||||
href="/upload"
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25"
|
||||
>
|
||||
<i className="fa-solid fa-cloud-arrow-up" />
|
||||
<span className="hidden sm:inline">Upload</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export default function Topbar({ user = null }) {
|
||||
</a>
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
|
||||
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
|
||||
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"
|
||||
|
||||
Reference in New Issue
Block a user