Improve projects UX, settings layout, uppercase names, per-page selector, and fix round deletion
Build and Push Docker Image / build (push) Successful in 10m7s
Details
Build and Push Docker Image / build (push) Successful in 10m7s
Details
- 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:
parent
829acf8d4e
commit
5c4200158f
|
|
@ -628,7 +628,7 @@ model ProjectFile {
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
round Round? @relation(fields: [roundId], references: [id])
|
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
|
||||||
requirement FileRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull)
|
requirement FileRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull)
|
||||||
replacedBy ProjectFile? @relation("FileVersions", fields: [replacedById], references: [id], onDelete: SetNull)
|
replacedBy ProjectFile? @relation("FileVersions", fields: [replacedById], references: [id], onDelete: SetNull)
|
||||||
replacements ProjectFile[] @relation("FileVersions")
|
replacements ProjectFile[] @relation("FileVersions")
|
||||||
|
|
@ -706,7 +706,7 @@ model Evaluation {
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||||
form EvaluationForm @relation(fields: [formId], references: [id])
|
form EvaluationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([submittedAt])
|
@@index([submittedAt])
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ const statusColors: Record<
|
||||||
|
|
||||||
function parseFiltersFromParams(
|
function parseFiltersFromParams(
|
||||||
searchParams: URLSearchParams
|
searchParams: URLSearchParams
|
||||||
): ProjectFilters & { page: number } {
|
): ProjectFilters & { page: number; perPage: number } {
|
||||||
return {
|
return {
|
||||||
search: searchParams.get('q') || '',
|
search: searchParams.get('q') || '',
|
||||||
statuses: searchParams.get('status')
|
statuses: searchParams.get('status')
|
||||||
|
|
@ -133,11 +133,12 @@ function parseFiltersFromParams(
|
||||||
? false
|
? false
|
||||||
: undefined,
|
: undefined,
|
||||||
page: parseInt(searchParams.get('page') || '1', 10),
|
page: parseInt(searchParams.get('page') || '1', 10),
|
||||||
|
perPage: parseInt(searchParams.get('pp') || '20', 10),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filtersToParams(
|
function filtersToParams(
|
||||||
filters: ProjectFilters & { page: number }
|
filters: ProjectFilters & { page: number; perPage: number }
|
||||||
): URLSearchParams {
|
): URLSearchParams {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (filters.search) params.set('q', filters.search)
|
if (filters.search) params.set('q', filters.search)
|
||||||
|
|
@ -155,11 +156,10 @@ function filtersToParams(
|
||||||
if (filters.hasAssignments !== undefined)
|
if (filters.hasAssignments !== undefined)
|
||||||
params.set('hasAssign', String(filters.hasAssignments))
|
params.set('hasAssign', String(filters.hasAssignments))
|
||||||
if (filters.page > 1) params.set('page', String(filters.page))
|
if (filters.page > 1) params.set('page', String(filters.page))
|
||||||
|
if (filters.perPage !== 20) params.set('pp', String(filters.perPage))
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
const PER_PAGE = 20
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
|
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
@ -178,8 +178,17 @@ export default function ProjectsPage() {
|
||||||
hasAssignments: parsed.hasAssignments,
|
hasAssignments: parsed.hasAssignments,
|
||||||
})
|
})
|
||||||
const [page, setPage] = useState(parsed.page)
|
const [page, setPage] = useState(parsed.page)
|
||||||
|
const [perPage, setPerPage] = useState(parsed.perPage || 20)
|
||||||
const [searchInput, setSearchInput] = useState(parsed.search)
|
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
|
// Debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|
@ -193,8 +202,8 @@ export default function ProjectsPage() {
|
||||||
|
|
||||||
// Sync URL
|
// Sync URL
|
||||||
const syncUrl = useCallback(
|
const syncUrl = useCallback(
|
||||||
(f: ProjectFilters, p: number) => {
|
(f: ProjectFilters, p: number, pp: number) => {
|
||||||
const params = filtersToParams({ ...f, page: p })
|
const params = filtersToParams({ ...f, page: p, perPage: pp })
|
||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
window.history.replaceState(null, '', qs ? `${pathname}?${qs}` : pathname)
|
window.history.replaceState(null, '', qs ? `${pathname}?${qs}` : pathname)
|
||||||
},
|
},
|
||||||
|
|
@ -202,8 +211,13 @@ export default function ProjectsPage() {
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncUrl(filters, page)
|
syncUrl(filters, page, perPage)
|
||||||
}, [filters, page, syncUrl])
|
}, [filters, page, perPage, syncUrl])
|
||||||
|
|
||||||
|
const handlePerPageChange = (newPerPage: number) => {
|
||||||
|
setPerPage(newPerPage)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Reset page when filters change
|
// Reset page when filters change
|
||||||
const handleFiltersChange = (newFilters: ProjectFilters) => {
|
const handleFiltersChange = (newFilters: ProjectFilters) => {
|
||||||
|
|
@ -248,7 +262,7 @@ export default function ProjectsPage() {
|
||||||
hasFiles: filters.hasFiles,
|
hasFiles: filters.hasFiles,
|
||||||
hasAssignments: filters.hasAssignments,
|
hasAssignments: filters.hasAssignments,
|
||||||
page,
|
page,
|
||||||
perPage: PER_PAGE,
|
perPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
@ -459,14 +473,14 @@ export default function ProjectsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* 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>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
|
||||||
<p className="text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href="/admin/projects/pool">
|
<Link href="/admin/projects/pool">
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -493,14 +507,30 @@ export default function ProjectsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
<div className="space-y-1">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
placeholder="Search projects by title, team, or description..."
|
placeholder="Search projects by title, team, or description..."
|
||||||
className="pl-10"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
|
|
@ -510,6 +540,37 @@ export default function ProjectsPage() {
|
||||||
onChange={handleFiltersChange}
|
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 */}
|
{/* Content */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -576,10 +637,11 @@ export default function ProjectsPage() {
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
<TableHead>Project</TableHead>
|
<TableHead className="min-w-[280px]">Project</TableHead>
|
||||||
<TableHead>Round</TableHead>
|
<TableHead>Round</TableHead>
|
||||||
<TableHead>Files</TableHead>
|
<TableHead>Files</TableHead>
|
||||||
<TableHead>Assignments</TableHead>
|
<TableHead>Assignments</TableHead>
|
||||||
|
<TableHead>Submitted</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -613,11 +675,14 @@ export default function ProjectsPage() {
|
||||||
fallback="initials"
|
fallback="initials"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium hover:text-primary">
|
<p className={`font-medium hover:text-primary ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||||
{truncate(project.title, 40)}
|
{truncate(project.title, 40)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{project.teamName}
|
{project.teamName}
|
||||||
|
{project.country && (
|
||||||
|
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -650,6 +715,11 @@ export default function ProjectsPage() {
|
||||||
{project._count.assignments}
|
{project._count.assignments}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -733,7 +803,7 @@ export default function ProjectsPage() {
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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}
|
{project.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
|
|
@ -761,6 +831,16 @@ export default function ProjectsPage() {
|
||||||
<span className="text-muted-foreground">Assignments</span>
|
<span className="text-muted-foreground">Assignments</span>
|
||||||
<span>{project._count.assignments} jurors</span>
|
<span>{project._count.assignments} jurors</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -773,8 +853,9 @@ export default function ProjectsPage() {
|
||||||
page={data.page}
|
page={data.page}
|
||||||
totalPages={data.totalPages}
|
totalPages={data.totalPages}
|
||||||
total={data.total}
|
total={data.total}
|
||||||
perPage={PER_PAGE}
|
perPage={perPage}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPerPageChange={handlePerPageChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Loader2, Settings } from 'lucide-react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
|
@ -41,6 +42,7 @@ const formSchema = z.object({
|
||||||
default_timezone: z.string().min(1, 'Timezone is required'),
|
default_timezone: z.string().min(1, 'Timezone is required'),
|
||||||
default_page_size: z.string().regex(/^\d+$/, 'Must be a number'),
|
default_page_size: z.string().regex(/^\d+$/, 'Must be a number'),
|
||||||
autosave_interval_seconds: z.string().regex(/^\d+$/, 'Must be a number'),
|
autosave_interval_seconds: z.string().regex(/^\d+$/, 'Must be a number'),
|
||||||
|
display_project_names_uppercase: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
@ -50,6 +52,7 @@ interface DefaultsSettingsFormProps {
|
||||||
default_timezone?: string
|
default_timezone?: string
|
||||||
default_page_size?: string
|
default_page_size?: string
|
||||||
autosave_interval_seconds?: string
|
autosave_interval_seconds?: string
|
||||||
|
display_project_names_uppercase?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +65,7 @@ export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) {
|
||||||
default_timezone: settings.default_timezone || 'Europe/Monaco',
|
default_timezone: settings.default_timezone || 'Europe/Monaco',
|
||||||
default_page_size: settings.default_page_size || '20',
|
default_page_size: settings.default_page_size || '20',
|
||||||
autosave_interval_seconds: settings.autosave_interval_seconds || '30',
|
autosave_interval_seconds: settings.autosave_interval_seconds || '30',
|
||||||
|
display_project_names_uppercase: settings.display_project_names_uppercase || 'true',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -81,6 +85,7 @@ export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) {
|
||||||
{ key: 'default_timezone', value: data.default_timezone },
|
{ key: 'default_timezone', value: data.default_timezone },
|
||||||
{ key: 'default_page_size', value: data.default_page_size },
|
{ key: 'default_page_size', value: data.default_page_size },
|
||||||
{ key: 'autosave_interval_seconds', value: data.autosave_interval_seconds },
|
{ key: 'autosave_interval_seconds', value: data.autosave_interval_seconds },
|
||||||
|
{ key: 'display_project_names_uppercase', value: data.display_project_names_uppercase || 'true' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +167,29 @@ export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="display_project_names_uppercase"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">
|
||||||
|
Display Project Names in Uppercase
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Show all project names in uppercase across the platform for a cleaner presentation
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value === 'true'}
|
||||||
|
onCheckedChange={(checked) => field.onChange(String(checked))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button type="submit" disabled={updateSettings.isPending}>
|
<Button type="submit" disabled={updateSettings.isPending}>
|
||||||
{updateSettings.isPending ? (
|
{updateSettings.isPending ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
'default_timezone',
|
'default_timezone',
|
||||||
'default_page_size',
|
'default_page_size',
|
||||||
'autosave_interval_seconds',
|
'autosave_interval_seconds',
|
||||||
|
'display_project_names_uppercase',
|
||||||
])
|
])
|
||||||
|
|
||||||
const digestSettings = getSettingsByKeys([
|
const digestSettings = getSettingsByKeys([
|
||||||
|
|
@ -152,58 +153,142 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue="ai" className="space-y-6">
|
<Tabs defaultValue="defaults" className="space-y-6">
|
||||||
<TabsList className="flex flex-wrap h-auto gap-1">
|
{/* Mobile: horizontal scrollable tabs */}
|
||||||
<TabsTrigger value="ai" className="gap-2">
|
<TabsList className="flex h-auto gap-1 overflow-x-auto whitespace-nowrap lg:hidden">
|
||||||
<Bot className="h-4 w-4" />
|
<TabsTrigger value="defaults" className="gap-2 shrink-0">
|
||||||
<span className="hidden sm:inline">AI</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="tags" className="gap-2">
|
|
||||||
<Tags className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Tags</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="branding" className="gap-2">
|
|
||||||
<Palette className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Branding</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="email" className="gap-2">
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Email</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="notifications" className="gap-2">
|
|
||||||
<Bell className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Notifications</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="storage" className="gap-2">
|
|
||||||
<HardDrive className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Storage</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="security" className="gap-2">
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Security</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="defaults" className="gap-2">
|
|
||||||
<SettingsIcon className="h-4 w-4" />
|
<SettingsIcon className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Defaults</span>
|
Defaults
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="digest" className="gap-2">
|
<TabsTrigger value="branding" className="gap-2 shrink-0">
|
||||||
<Newspaper className="h-4 w-4" />
|
<Palette className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Digest</span>
|
Branding
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="analytics" className="gap-2">
|
<TabsTrigger value="localization" className="gap-2 shrink-0">
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Analytics</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="audit" className="gap-2">
|
|
||||||
<ShieldAlert className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Audit</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="localization" className="gap-2">
|
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Locale</span>
|
Locale
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="email" className="gap-2 shrink-0">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Email
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="notifications" className="gap-2 shrink-0">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
Notif.
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="digest" className="gap-2 shrink-0">
|
||||||
|
<Newspaper className="h-4 w-4" />
|
||||||
|
Digest
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="security" className="gap-2 shrink-0">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Security
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="audit" className="gap-2 shrink-0">
|
||||||
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
Audit
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ai" className="gap-2 shrink-0">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
AI
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tags" className="gap-2 shrink-0">
|
||||||
|
<Tags className="h-4 w-4" />
|
||||||
|
Tags
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics" className="gap-2 shrink-0">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Analytics
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="storage" className="gap-2 shrink-0">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
Storage
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="lg:flex lg:gap-8">
|
||||||
|
{/* Desktop: sidebar navigation */}
|
||||||
|
<div className="hidden lg:block lg:w-56 lg:shrink-0">
|
||||||
|
<nav className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">General</p>
|
||||||
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
|
<TabsTrigger value="defaults" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<SettingsIcon className="h-4 w-4" />
|
||||||
|
Defaults
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="branding" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
Branding
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="localization" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
Locale
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Communication</p>
|
||||||
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
|
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Email
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="notifications" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
Notifications
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="digest" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<Newspaper className="h-4 w-4" />
|
||||||
|
Digest
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Security</p>
|
||||||
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
|
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Security
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="audit" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
Audit
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Features</p>
|
||||||
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
|
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
AI
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<Tags className="h-4 w-4" />
|
||||||
|
Tags
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Analytics
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
|
||||||
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||||
|
<TabsTrigger value="storage" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
Storage
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
|
||||||
<TabsContent value="ai" className="space-y-6">
|
<TabsContent value="ai" className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -390,6 +475,8 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</div>{/* end content area */}
|
||||||
|
</div>{/* end lg:flex */}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Quick Links to sub-pages */}
|
{/* Quick Links to sub-pages */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
interface PaginationProps {
|
interface PaginationProps {
|
||||||
|
|
@ -9,6 +16,7 @@ interface PaginationProps {
|
||||||
total: number
|
total: number
|
||||||
perPage: number
|
perPage: number
|
||||||
onPageChange: (page: number) => void
|
onPageChange: (page: number) => void
|
||||||
|
onPerPageChange?: (perPage: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Pagination({
|
export function Pagination({
|
||||||
|
|
@ -17,17 +25,38 @@ export function Pagination({
|
||||||
total,
|
total,
|
||||||
perPage,
|
perPage,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onPerPageChange,
|
||||||
}: PaginationProps) {
|
}: PaginationProps) {
|
||||||
if (totalPages <= 1) return null
|
if (totalPages <= 1 && !onPerPageChange) return null
|
||||||
|
|
||||||
const from = (page - 1) * perPage + 1
|
const from = (page - 1) * perPage + 1
|
||||||
const to = Math.min(page * perPage, total)
|
const to = Math.min(page * perPage, total)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Showing {from} to {to} of {total} results
|
Showing {from} to {to} of {total} results
|
||||||
</p>
|
</p>
|
||||||
|
{onPerPageChange && (
|
||||||
|
<Select
|
||||||
|
value={String(perPage)}
|
||||||
|
onValueChange={(v) => onPerPageChange(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[10, 20, 50, 100].map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -51,6 +80,7 @@ export function Pagination({
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue