Improve projects UX, settings layout, uppercase names, per-page selector, and fix round deletion
- Fix round deletion FK constraint: add onDelete Cascade on Evaluation.form and SetNull on ProjectFile.round - Add configurable per-page selector (10/20/50/100) to Pagination component, wired in projects page with URL sync - Add display_project_names_uppercase setting in admin defaults, applied to project titles across desktop/mobile views - Redesign admin settings page: vertical sidebar nav on desktop with grouped sections, horizontal scrollable tabs on mobile - Polish projects page: responsive header with total count, search clear button with result count, status stats bar, submission date column, country display, mobile card file count Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -104,7 +104,7 @@ const statusColors: Record<
|
||||
|
||||
function parseFiltersFromParams(
|
||||
searchParams: URLSearchParams
|
||||
): ProjectFilters & { page: number } {
|
||||
): ProjectFilters & { page: number; perPage: number } {
|
||||
return {
|
||||
search: searchParams.get('q') || '',
|
||||
statuses: searchParams.get('status')
|
||||
@@ -133,11 +133,12 @@ function parseFiltersFromParams(
|
||||
? false
|
||||
: undefined,
|
||||
page: parseInt(searchParams.get('page') || '1', 10),
|
||||
perPage: parseInt(searchParams.get('pp') || '20', 10),
|
||||
}
|
||||
}
|
||||
|
||||
function filtersToParams(
|
||||
filters: ProjectFilters & { page: number }
|
||||
filters: ProjectFilters & { page: number; perPage: number }
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.search) params.set('q', filters.search)
|
||||
@@ -155,11 +156,10 @@ function filtersToParams(
|
||||
if (filters.hasAssignments !== undefined)
|
||||
params.set('hasAssign', String(filters.hasAssignments))
|
||||
if (filters.page > 1) params.set('page', String(filters.page))
|
||||
if (filters.perPage !== 20) params.set('pp', String(filters.perPage))
|
||||
return params
|
||||
}
|
||||
|
||||
const PER_PAGE = 20
|
||||
|
||||
export default function ProjectsPage() {
|
||||
|
||||
const pathname = usePathname()
|
||||
@@ -178,8 +178,17 @@ export default function ProjectsPage() {
|
||||
hasAssignments: parsed.hasAssignments,
|
||||
})
|
||||
const [page, setPage] = useState(parsed.page)
|
||||
const [perPage, setPerPage] = useState(parsed.perPage || 20)
|
||||
const [searchInput, setSearchInput] = useState(parsed.search)
|
||||
|
||||
// Fetch display settings
|
||||
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
|
||||
keys: ['display_project_names_uppercase'],
|
||||
})
|
||||
const uppercaseNames = displaySettings?.find(
|
||||
(s: { key: string; value: string }) => s.key === 'display_project_names_uppercase'
|
||||
)?.value !== 'false'
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -193,8 +202,8 @@ export default function ProjectsPage() {
|
||||
|
||||
// Sync URL
|
||||
const syncUrl = useCallback(
|
||||
(f: ProjectFilters, p: number) => {
|
||||
const params = filtersToParams({ ...f, page: p })
|
||||
(f: ProjectFilters, p: number, pp: number) => {
|
||||
const params = filtersToParams({ ...f, page: p, perPage: pp })
|
||||
const qs = params.toString()
|
||||
window.history.replaceState(null, '', qs ? `${pathname}?${qs}` : pathname)
|
||||
},
|
||||
@@ -202,8 +211,13 @@ export default function ProjectsPage() {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
syncUrl(filters, page)
|
||||
}, [filters, page, syncUrl])
|
||||
syncUrl(filters, page, perPage)
|
||||
}, [filters, page, perPage, syncUrl])
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// Reset page when filters change
|
||||
const handleFiltersChange = (newFilters: ProjectFilters) => {
|
||||
@@ -248,7 +262,7 @@ export default function ProjectsPage() {
|
||||
hasFiles: filters.hasFiles,
|
||||
hasAssignments: filters.hasAssignments,
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
perPage,
|
||||
}
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
@@ -459,14 +473,14 @@ export default function ProjectsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage submitted projects across all rounds
|
||||
{data ? `${data.total} projects across all rounds` : 'Manage submitted projects across all rounds'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/projects/pool">
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
@@ -493,14 +507,30 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search projects by title, team, or description..."
|
||||
className="pl-10"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search projects by title, team, or description..."
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchInput('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filters.search && data && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
{data.total} result{data.total !== 1 ? 's' : ''} for “{filters.search}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -510,6 +540,37 @@ export default function ProjectsPage() {
|
||||
onChange={handleFiltersChange}
|
||||
/>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{data && data.projects.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{Object.entries(
|
||||
data.projects.reduce<Record<string, number>>((acc, p) => {
|
||||
const s = p.status ?? 'SUBMITTED'
|
||||
acc[s] = (acc[s] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
.sort(([a], [b]) => {
|
||||
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
|
||||
return order.indexOf(a) - order.indexOf(b)
|
||||
})
|
||||
.map(([status, count]) => (
|
||||
<Badge
|
||||
key={status}
|
||||
variant={statusColors[status] || 'secondary'}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
{data.total > data.projects.length && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
(page {data.page} of {data.totalPages})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
@@ -576,10 +637,11 @@ export default function ProjectsPage() {
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Files</TableHead>
|
||||
<TableHead>Assignments</TableHead>
|
||||
<TableHead>Submitted</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -613,11 +675,14 @@ export default function ProjectsPage() {
|
||||
fallback="initials"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium hover:text-primary">
|
||||
<p className={`font-medium hover:text-primary ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||
{truncate(project.title, 40)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.teamName}
|
||||
{project.country && (
|
||||
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -650,6 +715,11 @@ export default function ProjectsPage() {
|
||||
{project._count.assignments}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{project.createdAt
|
||||
? new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||
</TableCell>
|
||||
@@ -733,7 +803,7 @@ export default function ProjectsPage() {
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base line-clamp-2">
|
||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<StatusBadge
|
||||
@@ -761,6 +831,16 @@ export default function ProjectsPage() {
|
||||
<span className="text-muted-foreground">Assignments</span>
|
||||
<span>{project._count.assignments} jurors</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Files</span>
|
||||
<span>{project._count?.files ?? 0}</span>
|
||||
</div>
|
||||
{project.createdAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -773,8 +853,9 @@ export default function ProjectsPage() {
|
||||
page={data.page}
|
||||
totalPages={data.totalPages}
|
||||
total={data.total}
|
||||
perPage={PER_PAGE}
|
||||
perPage={perPage}
|
||||
onPageChange={setPage}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user