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:
@@ -8,6 +8,7 @@ import { Loader2, Settings } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -41,6 +42,7 @@ const formSchema = z.object({
|
||||
default_timezone: z.string().min(1, 'Timezone is required'),
|
||||
default_page_size: 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>
|
||||
@@ -50,6 +52,7 @@ interface DefaultsSettingsFormProps {
|
||||
default_timezone?: string
|
||||
default_page_size?: 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_page_size: settings.default_page_size || '20',
|
||||
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_page_size', value: data.default_page_size },
|
||||
{ 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}>
|
||||
{updateSettings.isPending ? (
|
||||
<>
|
||||
|
||||
@@ -116,6 +116,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||
'default_timezone',
|
||||
'default_page_size',
|
||||
'autosave_interval_seconds',
|
||||
'display_project_names_uppercase',
|
||||
])
|
||||
|
||||
const digestSettings = getSettingsByKeys([
|
||||
@@ -152,58 +153,142 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue="ai" className="space-y-6">
|
||||
<TabsList className="flex flex-wrap h-auto gap-1">
|
||||
<TabsTrigger value="ai" className="gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<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">
|
||||
<Tabs defaultValue="defaults" className="space-y-6">
|
||||
{/* Mobile: horizontal scrollable tabs */}
|
||||
<TabsList className="flex h-auto gap-1 overflow-x-auto whitespace-nowrap lg:hidden">
|
||||
<TabsTrigger value="defaults" className="gap-2 shrink-0">
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Defaults</span>
|
||||
Defaults
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="digest" className="gap-2">
|
||||
<Newspaper className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Digest</span>
|
||||
<TabsTrigger value="branding" className="gap-2 shrink-0">
|
||||
<Palette className="h-4 w-4" />
|
||||
Branding
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics" className="gap-2">
|
||||
<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">
|
||||
<TabsTrigger value="localization" className="gap-2 shrink-0">
|
||||
<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>
|
||||
</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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -390,6 +475,8 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>{/* end content area */}
|
||||
</div>{/* end lg:flex */}
|
||||
</Tabs>
|
||||
|
||||
{/* Quick Links to sub-pages */}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface PaginationProps {
|
||||
@@ -9,6 +16,7 @@ interface PaginationProps {
|
||||
total: number
|
||||
perPage: number
|
||||
onPageChange: (page: number) => void
|
||||
onPerPageChange?: (perPage: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
@@ -17,40 +25,62 @@ export function Pagination({
|
||||
total,
|
||||
perPage,
|
||||
onPageChange,
|
||||
onPerPageChange,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null
|
||||
if (totalPages <= 1 && !onPerPageChange) return null
|
||||
|
||||
const from = (page - 1) * perPage + 1
|
||||
const to = Math.min(page * perPage, total)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {from} to {to} of {total} results
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {from} to {to} of {total} results
|
||||
</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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user