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:
commit
44f3d696e4
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
20
viewer-frontend/components/ThemeProviderWrapper.tsx
Normal file
20
viewer-frontend/components/ThemeProviderWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
viewer-frontend/components/ThemeToggle.tsx
Normal file
83
viewer-frontend/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 && (
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
68
viewer-frontend/hooks/useFocusTrap.ts
Normal file
68
viewer-frontend/hooks/useFocusTrap.ts
Normal 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]);
|
||||
}
|
||||
11
viewer-frontend/package-lock.json
generated
11
viewer-frontend/package-lock.json
generated
@ -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": [
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user