Merge pull request 'feat: add dark mode and audit viewer-frontend accessibility' (#59) from a11y/dark-mode-and-focus-management into dev

This commit is contained in:
ilia 2026-07-14 15:25:46 -05:00
commit 44f3d696e4
32 changed files with 371 additions and 146 deletions

View File

@ -997,8 +997,8 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
/>
<div className="flex-1 min-w-0">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<div role="status" aria-label="Loading photos" className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</div>
) : (
<>
@ -1046,9 +1046,9 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
/>
{/* Infinite scroll sentinel */}
<div ref={observerTarget} className="h-10 flex items-center justify-center">
<div ref={observerTarget} role="status" aria-label={loadingMore ? 'Loading more photos' : undefined} className="h-10 flex items-center justify-center">
{loadingMore && (
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
<Loader2 className="h-6 w-6 animate-spin text-gray-400 dark:text-gray-500" />
)}
</div>

View File

@ -331,13 +331,13 @@ export function ManageUsersContent() {
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-400">
<div role="alert" className="mb-4 rounded-md bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{successMessage && (
<div className="mb-4 rounded-md bg-green-50 p-4 text-green-800 dark:bg-green-900/20 dark:text-green-400">
<div role="status" className="mb-4 rounded-md bg-green-50 p-4 text-green-800 dark:bg-green-900/20 dark:text-green-400">
{successMessage}
</div>
)}
@ -389,6 +389,7 @@ export function ManageUsersContent() {
variant="ghost"
size="sm"
onClick={() => openEditDialog(user)}
aria-label={`Edit ${user.email}`}
>
<Edit2 className="h-4 w-4" />
</Button>
@ -403,6 +404,7 @@ export function ManageUsersContent() {
setDeleteConfirmOpen(true);
}}
className="text-red-600 hover:text-red-700"
aria-label={`Deactivate ${user.email}`}
>
<Trash2 className="h-4 w-4" />
</Button>
@ -553,7 +555,7 @@ export function ManageUsersContent() {
</div>
<div className="grid gap-2">
<label htmlFor="edit-password" className="text-sm font-medium">
New Password <span className="text-gray-500 font-normal">(leave empty to keep current)</span>
New Password <span className="text-gray-500 dark:text-gray-400 font-normal">(leave empty to keep current)</span>
</label>
<Input
id="edit-password"

View File

@ -8,6 +8,7 @@ import { ManageUsersContent } from './ManageUsersContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ThemeToggle } from '@/components/ThemeToggle';
interface ManageUsersPageClientProps {
onClose?: () => void;
@ -30,38 +31,35 @@ export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) {
const overlayContent = (
<div className="fixed inset-0 z-[100] bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close manage users"
>
<X className="h-5 w-5" />
</Button>
</div>
<main id="main-content" className="w-full px-4 pt-3 pb-8">
{/* Header */}
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 mb-3 border-b">
<div className="mb-2 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
className="h-11 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ThemeToggle />
<UserMenu />
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close manage users"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
<p className="text-sm font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
@ -70,7 +68,7 @@ export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) {
<div className="mt-8">
<ManageUsersContent />
</div>
</div>
</main>
</div>
);

View File

@ -20,7 +20,7 @@ export default function GlobalError({
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
<main id="main-content" role="alert" className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<h1 className="text-xl font-semibold text-secondary">Something went wrong</h1>
<p className="max-w-md text-sm text-muted-foreground">
An unexpected error occurred. You can try again, or head back to the homepage.
@ -33,6 +33,6 @@ export default function GlobalError({
Go home
</Button>
</div>
</div>
</main>
);
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SessionProviderWrapper } from "@/components/SessionProviderWrapper";
import { ThemeProviderWrapper } from "@/components/ThemeProviderWrapper";
import "./globals.css";
const inter = Inter({
@ -19,11 +20,19 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<SessionProviderWrapper>
{children}
</SessionProviderWrapper>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[200] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to main content
</a>
<ThemeProviderWrapper>
<SessionProviderWrapper>
{children}
</SessionProviderWrapper>
</ThemeProviderWrapper>
</body>
</html>
);

View File

@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ThemeToggle';
function LoginForm() {
const router = useRouter();
@ -99,13 +100,14 @@ function LoginForm() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="relative flex min-h-screen items-center justify-center bg-background px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
href="/register"
@ -117,44 +119,44 @@ function LoginForm() {
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{registered && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
<div role="status" className="rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<p className="text-sm text-green-800 dark:text-green-300">
Account created successfully! Please check your email to confirm your account before signing in.
</p>
</div>
)}
{verified && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
<div role="status" className="rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<p className="text-sm text-green-800 dark:text-green-300">
Email verified successfully! You can now sign in.
</p>
</div>
)}
{passwordReset && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
<div role="status" className="rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<p className="text-sm text-green-800 dark:text-green-300">
Password reset successfully! You can now sign in with your new password.
</p>
</div>
)}
{emailNotVerified && (
<div className="rounded-md bg-yellow-50 p-4">
<p className="text-sm text-yellow-800 mb-2">
<div role="alert" className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-2">
Please verify your email address before signing in. Check your inbox for a confirmation email.
</p>
<button
type="button"
onClick={handleResendConfirmation}
disabled={isResending}
className="text-sm text-yellow-900 underline hover:no-underline font-medium"
className="text-sm text-yellow-900 dark:text-yellow-200 underline hover:no-underline font-medium"
>
{isResending ? 'Sending...' : 'Resend confirmation email'}
</button>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
<div role="alert" className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
@ -209,23 +211,23 @@ function LoginForm() {
</div>
</form>
</div>
</div>
</main>
);
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">Loading...</p>
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
</div>
</main>
}>
<LoginForm />
</Suspense>

View File

@ -222,10 +222,10 @@ export default async function HomePage() {
]);
return (
<main className="w-full px-4 py-8">
<main id="main-content" className="w-full px-4 pt-3 pb-8">
{error ? (
<div className="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-200">
<div role="alert" className="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-200">
<p className="font-semibold">Error loading photos</p>
<p className="text-sm">{error}</p>
<p className="mt-2 text-xs">

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { isValidEmail } from '@/lib/utils';
import { ThemeToggle } from '@/components/ThemeToggle';
export default function RegisterPage() {
const router = useRouter();
@ -74,13 +75,14 @@ export default function RegisterPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="relative flex min-h-screen items-center justify-center bg-background px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
href="/login"
@ -92,8 +94,8 @@ export default function RegisterPage() {
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
<div role="alert" className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
@ -144,7 +146,7 @@ export default function RegisterPage() {
className="mt-1"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 6 characters
</p>
</div>
@ -177,7 +179,7 @@ export default function RegisterPage() {
</div>
</form>
</div>
</div>
</main>
);
}

View File

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ThemeToggle';
function ResetPasswordForm() {
const router = useRouter();
@ -71,18 +72,19 @@ function ResetPasswordForm() {
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="relative flex min-h-screen items-center justify-center bg-background px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Password reset successful
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Your password has been reset successfully. Redirecting to login...
</p>
</div>
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
<div role="status" className="rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<p className="text-sm text-green-800 dark:text-green-300">
You can now sign in with your new password.
</p>
</div>
@ -95,25 +97,26 @@ function ResetPasswordForm() {
</Link>
</div>
</div>
</div>
</main>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="relative flex min-h-screen items-center justify-center bg-background px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Enter your new password below
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
<div role="alert" className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
@ -132,7 +135,7 @@ function ResetPasswordForm() {
className="mt-1"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 6 characters
</p>
</div>
@ -173,23 +176,23 @@ function ResetPasswordForm() {
</div>
</form>
</div>
</div>
</main>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">Loading...</p>
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
</div>
</main>
}>
<ResetPasswordForm />
</Suspense>

View File

@ -146,8 +146,8 @@ export function SearchContent({ people, tags }: SearchContentProps) {
{/* Results */}
<div className="lg:col-span-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<div role="status" aria-label="Loading photos" className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</div>
) : (
<>
@ -192,11 +192,11 @@ export function SearchContent({ people, tags }: SearchContentProps) {
</>
) : hasActiveFilters ? (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">No photos found matching your filters</p>
<p className="text-gray-500 dark:text-gray-400">No photos found matching your filters</p>
</div>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">Select filters to search photos</p>
<p className="text-gray-500 dark:text-gray-400">Select filters to search photos</p>
</div>
)}
</>

View File

@ -97,7 +97,7 @@ export default async function SearchPage() {
]);
return (
<main className="w-full px-4 py-8">
<main id="main-content" className="w-full px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Search Photos
@ -108,8 +108,8 @@ export default async function SearchPage() {
</div>
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Loading search...</div>
<div role="status" className="flex items-center justify-center py-12">
<div className="text-gray-500 dark:text-gray-400">Loading search...</div>
</div>
}>
<SearchContent people={people} tags={tags} />

View File

@ -48,7 +48,7 @@ export default function TestImagesPage() {
];
return (
<main className="container mx-auto px-4 py-8">
<main id="main-content" className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Image Source Test Page

View File

@ -350,7 +350,7 @@ export function UploadContent() {
</div>
{allSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
<div role="status" className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
<p className="text-sm font-medium text-green-800 dark:text-green-200">

View File

@ -7,6 +7,7 @@ import { UploadContent } from './UploadContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ThemeToggle } from '@/components/ThemeToggle';
export function UploadPageClient() {
const router = useRouter();
@ -17,38 +18,35 @@ export function UploadPageClient() {
return (
<div className="fixed inset-0 z-50 bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close upload"
>
<X className="h-5 w-5" />
</Button>
</div>
<main id="main-content" className="w-full px-4 pt-3 pb-8">
{/* Header */}
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 mb-3 border-b">
<div className="mb-2 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
className="h-11 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ThemeToggle />
<UserMenu />
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close upload"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
<p className="text-sm font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
@ -65,7 +63,7 @@ export function UploadPageClient() {
</div>
<UploadContent />
</div>
</div>
</main>
</div>
);
}

View File

@ -91,7 +91,7 @@ export function ForgotPasswordDialog({
</DialogHeader>
{success ? (
<div className="space-y-4">
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<div role="status" className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Password reset email sent! Please check your inbox and follow the instructions to reset your password.
</p>
@ -105,7 +105,7 @@ export function ForgotPasswordDialog({
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<div role="alert" className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}

View File

@ -273,7 +273,7 @@ export function IdentifyFaceDialog({
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Please sign in or create an account to continue.
</p>
<div className="flex gap-2">
@ -363,7 +363,7 @@ export function IdentifyFaceDialog({
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Only users with write access can identify faces. Please contact an administrator to request write access.
</p>
</div>
@ -485,7 +485,7 @@ export function IdentifyFaceDialog({
const fullName = `${person.firstName} ${person.lastName} ${person.middleName || ''} ${person.maidenName || ''}`.toLowerCase();
return fullName.includes(peopleSearchQuery.toLowerCase());
}).length === 0 ? (
<p className="p-2 text-sm text-gray-500">No people found</p>
<p className="p-2 text-sm text-gray-500 dark:text-gray-400">No people found</p>
) : (
<div className="space-y-1">
{people
@ -512,7 +512,7 @@ export function IdentifyFaceDialog({
{person.firstName} {person.lastName}
</div>
{(person.middleName || person.maidenName) && (
<div className="text-xs text-gray-500">
<div className="text-xs text-gray-500 dark:text-gray-400">
{[person.middleName, person.maidenName].filter(Boolean).join(' • ')}
</div>
)}

View File

@ -185,21 +185,21 @@ export function LoginDialog({
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{registered && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<div role="status" className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Account created successfully! Please check your email to confirm your account before signing in.
</p>
</div>
)}
{searchParams.get('verified') === 'true' && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<div role="status" className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Email verified successfully! You can now sign in.
</p>
</div>
)}
{emailNotVerified && (
<div className="rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
<div role="alert" className="rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2">
Please verify your email address before signing in. Check your inbox for a confirmation email.
</p>
@ -214,7 +214,7 @@ export function LoginDialog({
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<div role="alert" className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}

View File

@ -4,6 +4,7 @@ import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ActionButtons } from '@/components/ActionButtons';
import { ThemeToggle } from '@/components/ThemeToggle';
interface PageHeaderProps {
photosCount: number;
@ -33,26 +34,27 @@ export function PageHeader({
onToggleSelectionMode,
}: PageHeaderProps) {
return (
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 mb-3 border-b">
<div className="mb-2 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
className="h-11 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ThemeToggle />
<UserMenu />
</div>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
<h1 className="text-sm font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</h1>
<ActionButtons
photosCount={photosCount}
isLoggedIn={isLoggedIn}

View File

@ -794,13 +794,13 @@ export function PhotoGrid({
value={reportDialogComment}
onChange={(event) => setReportDialogComment(event.target.value)}
maxLength={REPORT_COMMENT_MAX_LENGTH}
className="mt-2 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900"
className="mt-2 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-secondary px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-100"
rows={4}
placeholder="Add a short note about why this photo should be reviewed..."
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<div className="mt-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{`${reportDialogComment.length}/${REPORT_COMMENT_MAX_LENGTH} characters`}</span>
{reportDialogError && <span className="text-red-600">{reportDialogError}</span>}
{reportDialogError && <span role="alert" className="text-red-600 dark:text-red-400">{reportDialogError}</span>}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
@ -836,7 +836,7 @@ export function PhotoGrid({
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Please sign in or create an account to continue.
</p>
<div className="flex gap-2">

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
@ -32,6 +33,8 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
const [loading, setLoading] = useState(false);
const { data: session } = useSession();
const isLoggedIn = Boolean(session);
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, true);
// Keyboard navigation
useEffect(() => {
@ -80,7 +83,14 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
const tags = (photo as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`Photo viewer: ${photo.filename}`}
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-black outline-none"
>
{/* Close Button */}
<Button
variant="ghost"

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import Image from 'next/image';
@ -97,6 +98,8 @@ export function PhotoViewerClient({
const router = useRouter();
const { data: session, update } = useSession();
const isLoggedIn = Boolean(session);
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, true);
// Check if user has write access
const hasWriteAccess = session?.user?.hasWriteAccess === true;
@ -1189,7 +1192,14 @@ export function PhotoViewerClient({
const hasNext = allPhotos.length > 0 && currentIdx < allPhotos.length - 1;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`Photo viewer: ${currentPhoto.filename}`}
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-black outline-none"
>
{/* Close Button */}
<Button
variant="ghost"

View File

@ -167,7 +167,7 @@ export function RegisterDialog({
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<div role="alert" className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}

View File

@ -3,26 +3,28 @@
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ThemeToggle } from '@/components/ThemeToggle';
export function SimpleHeader() {
return (
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 mb-3 border-b">
<div className="mb-2 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
className="h-11 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ThemeToggle />
<UserMenu />
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
<p className="text-sm font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>

View File

@ -0,0 +1,20 @@
'use client';
import { ThemeProvider } from 'next-themes';
export function ThemeProviderWrapper({
children,
}: {
children: React.ReactNode;
}) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
);
}

View File

@ -0,0 +1,83 @@
'use client';
import { useSyncExternalStore } from 'react';
import { useTheme } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface ThemeToggleProps {
className?: string;
}
const emptySubscribe = () => () => {};
/**
* True once React has hydrated on the client. Using useSyncExternalStore
* (rather than a useState+useEffect pair) avoids the "setState during an
* effect" pitfall while still letting the server render a neutral
* placeholder so the theme icon never mismatches during hydration.
*/
function useMounted() {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false
);
}
/**
* Accessible light/dark mode toggle. Renders a neutral placeholder until
* mounted so the server-rendered markup never guesses the wrong icon and
* causes a hydration mismatch (theme is only known client-side).
*/
export function ThemeToggle({ className }: ThemeToggleProps) {
const { resolvedTheme, setTheme } = useTheme();
const mounted = useMounted();
if (!mounted) {
return (
<Button
variant="ghost"
size="icon"
className={className}
aria-hidden="true"
tabIndex={-1}
disabled
>
<Sun className="h-5 w-5 opacity-0" />
</Button>
);
}
const isDark = resolvedTheme === 'dark';
const label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
aria-label={label}
aria-pressed={isDark}
className={className}
>
{isDark ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}

View File

@ -36,7 +36,7 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co
{isExpanded ? (
<>
<div className="flex items-center gap-2">
<Search className="h-4 w-4" />
<Search className="h-4 w-4" aria-hidden="true" />
<span className="font-medium text-secondary">Search & Filter</span>
{hasActiveFilters && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
@ -53,6 +53,8 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co
size="sm"
onClick={() => setIsExpanded(false)}
className="h-8 w-8 p-0"
aria-label="Collapse search and filter panel"
aria-expanded="true"
>
<ChevronLeft className="h-4 w-4" />
</Button>
@ -65,6 +67,8 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co
onClick={() => setIsExpanded(true)}
className="h-8 w-8 p-0 relative"
title="Expand search"
aria-label="Expand search and filter panel"
aria-expanded="false"
>
<ChevronRight className="h-4 w-4" />
{hasActiveFilters && (

View File

@ -74,7 +74,7 @@ export function PeopleFilter({ people, selected, mode, onSelectionChange, onMode
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredPeople.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No people found</p>
<p className="p-2 text-sm text-gray-500 dark:text-gray-400">No people found</p>
) : (
<div className="space-y-1">
{filteredPeople.map((person) => {

View File

@ -24,7 +24,7 @@ export function SearchBar({ onSearch, placeholder = 'Search photos...', defaultV
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
<Input
type="text"
placeholder={placeholder}

View File

@ -79,7 +79,7 @@ export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChang
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredTags.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No tags found</p>
<p className="p-2 text-sm text-gray-500 dark:text-gray-400">No tags found</p>
) : (
<div className="space-y-1">
{filteredTags.map((tag) => {

View File

@ -0,0 +1,68 @@
import { useEffect, type RefObject } from 'react';
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Traps keyboard focus inside a container while it's active (e.g. a
* full-screen custom modal that isn't built on Radix Dialog). Moves focus
* into the container on activation and restores focus to whatever was
* focused beforehand once the trap deactivates/unmounts.
*/
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean
) {
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusFirstElement = () => {
const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (focusable.length > 0) {
focusable[0].focus();
} else {
container.focus();
}
};
// Defer so the container's children have mounted.
const raf = requestAnimationFrame(focusFirstElement);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeElement = document.activeElement;
if (e.shiftKey) {
if (activeElement === first || !container.contains(activeElement)) {
e.preventDefault();
last.focus();
}
} else if (activeElement === last || !container.contains(activeElement)) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown, true);
return () => {
cancelAnimationFrame(raf);
document.removeEventListener('keydown', handleKeyDown, true);
previouslyFocused?.focus?.();
};
}, [active, containerRef]);
}

View File

@ -27,6 +27,7 @@
"lucide-react": "^0.553.0",
"next": "^16.1.1",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"prisma": "^6.19.0",
"react": "19.2.0",
@ -6019,6 +6020,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"funding": [

View File

@ -38,6 +38,7 @@
"lucide-react": "^0.553.0",
"next": "^16.1.1",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"prisma": "^6.19.0",
"react": "19.2.0",