feat: add dark mode and audit viewer-frontend accessibility
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 11s

Add next-themes light/dark/system theming with an accessible toggle
in every header, and close out an a11y pass across the viewer app:
skip-to-content link, main landmarks and heading hierarchy on every
route, focus-trapped dialog semantics for the photo viewer modals,
aria-label/live-region coverage for dynamic messages and icon
buttons, and dark: variants for previously light-only colors.
This commit is contained in:
2026-07-14 16:24:51 -04:00
parent e54c60912c
commit c011c8078e
32 changed files with 371 additions and 146 deletions
+4 -4
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>
@@ -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"
@@ -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>
);
+2 -2
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>
);
}
+13 -4
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>
);
+23 -21
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>
+2 -2
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">
+10 -8
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>
);
}
+23 -20
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>
+4 -4
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>
)}
</>
+3 -3
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} />
+1 -1
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
+1 -1
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">
+18 -20
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>
);
}