From bb0915913385bb442c6a2e18275f610764b58265 Mon Sep 17 00:00:00 2001 From: ilia Date: Tue, 14 Jul 2026 12:44:33 -0400 Subject: [PATCH 01/19] fix: prevent sign-out crash and surface real email-send failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NextAuth session/jwt callbacks no longer return null (the client useSession() hook chokes on a null session, crashing the app on sign-out for deactivated/refreshed sessions) — fixes #57 - UserMenu/Header sign-out handlers close the Manage Users overlay and popover before calling signOut(), and no longer let a rejected signOut() promise go unhandled - Added an app-level error boundary as a safety net for any remaining client render errors - Registration and forgot-password flows now tell the user honestly when the confirmation/reset email failed to send instead of always claiming success — related to #56 - lib/email.ts throws a clear, actionable error when neither SMTP nor Resend is configured, instead of a cryptic SDK error - Documented required SMTP/Resend env vars in DEPLOYMENT_CHECKLIST.md (were missing entirely, which is why no confirmation/reset emails were ever sent in deployed environments) — fixes #56 --- DEPLOYMENT_CHECKLIST.md | 21 ++++++++++ .../app/api/auth/[...nextauth]/route.ts | 25 +++++++----- .../app/api/auth/register/route.ts | 14 +++++-- viewer-frontend/app/error.tsx | 38 +++++++++++++++++++ viewer-frontend/components/Header.tsx | 12 +++++- viewer-frontend/components/RegisterDialog.tsx | 6 ++- viewer-frontend/components/UserMenu.tsx | 12 +++++- viewer-frontend/hooks/useIdleLogout.ts | 4 +- viewer-frontend/lib/email.ts | 7 ++++ 9 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 viewer-frontend/app/error.tsx diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md index f69deed..8d57d52 100644 --- a/DEPLOYMENT_CHECKLIST.md +++ b/DEPLOYMENT_CHECKLIST.md @@ -31,8 +31,29 @@ DATABASE_URL_AUTH=postgresql://user:password@10.0.10.179:5432/punimtag_auth NEXTAUTH_URL=http://10.0.10.121:3001 NEXTAUTH_SECRET=your-secret-key-here AUTH_URL=http://10.0.10.121:3001 + +# Password reset / email verification emails (lib/email.ts). Without these, +# "Forgot password" and "Confirm your email" never actually send mail — see +# issue #56. Pick ONE provider path: + +# Option A: SMTP (primary; falls back to Resend automatically on failure) +EMAIL_PROVIDER=smtp +SMTP_HOST=mail.your-domain.com +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER=mailer@your-domain.com +SMTP_PASS=your-mailbox-password +SMTP_FROM_EMAIL=noreply@your-domain.com +SMTP_FROM_NAME=PunimTag Viewer + +# Option B: Resend only (set EMAIL_PROVIDER=resend, or leave SMTP_* unset above) +RESEND_API_KEY=re_xxx +RESEND_FROM_EMAIL=onboarding@resend.dev +RESEND_FROM_NAME=PunimTag Viewer ``` +Verify email sending works with `cd viewer-frontend && npx tsx scripts/test-email-sending.ts` after setting these. + ## 2. PM2 Configuration Copy the template and customize for your server: diff --git a/viewer-frontend/app/api/auth/[...nextauth]/route.ts b/viewer-frontend/app/api/auth/[...nextauth]/route.ts index 244c9b4..f70bec7 100644 --- a/viewer-frontend/app/api/auth/[...nextauth]/route.ts +++ b/viewer-frontend/app/api/auth/[...nextauth]/route.ts @@ -99,7 +99,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ token.email = user.email; token.isAdmin = user.isAdmin; token.hasWriteAccess = user.hasWriteAccess; - token.exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now + token.isActive = true; } // Refresh user data from database on token refresh to get latest hasWriteAccess and isActive @@ -117,12 +117,15 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }, }); - if (dbUser) { - // Check if user is still active (treat null/undefined as true) - if (dbUser.isActive === false) { - // User was deactivated, invalidate token - return null as any; - } + if (!dbUser || dbUser.isActive === false) { + // User was deactivated or deleted. Flag it on the token instead of + // returning `null` here: NextAuth v5's client `useSession()` hook does + // not expect a `null` JWT and throws when it encounters one, which is + // what caused the sign-out crash (issue #57). The `session` callback + // below turns this flag into a clean signed-out session instead. + token.isActive = false; + } else { + token.isActive = true; token.id = dbUser.id.toString(); token.isAdmin = dbUser.isAdmin; token.hasWriteAccess = dbUser.hasWriteAccess; @@ -136,9 +139,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ return token; }, async session({ session, token }) { - // If token is null or expired, return null session to force logout - if (!token || (token.exp && token.exp < Math.floor(Date.now() / 1000))) { - return null as any; + // Never return `null` for the whole session object — NextAuth v5's client + // hooks assume a `{ user, expires }` shape and crash on `null` (issue #57). + // Instead, drop `user` so the client treats this as a normal signed-out state. + if (!token || token.isActive === false) { + return { ...session, user: undefined } as any; } if (session.user) { diff --git a/viewer-frontend/app/api/auth/register/route.ts b/viewer-frontend/app/api/auth/register/route.ts index f4f1567..c70bef0 100644 --- a/viewer-frontend/app/api/auth/register/route.ts +++ b/viewer-frontend/app/api/auth/register/route.ts @@ -83,19 +83,25 @@ export async function POST(request: NextRequest) { }); // Send confirmation email + let emailSendFailed = false; try { await sendEmailConfirmation(email, name.trim(), confirmationToken); } catch (emailError) { console.error('Error sending confirmation email:', emailError); - // Don't fail registration if email fails, but log it - // User can request a resend later + // Don't fail registration if email fails, but let the client know so it + // doesn't tell the user to "check their email" when nothing was sent + // (issue #56). The user can still request a resend later. + emailSendFailed = true; } return NextResponse.json( { - message: 'User created successfully. Please check your email to confirm your account.', + message: emailSendFailed + ? 'Account created, but we could not send the confirmation email right now. Use "Resend confirmation email" on the login screen once you try to sign in.' + : 'User created successfully. Please check your email to confirm your account.', user, - requiresEmailConfirmation: true + requiresEmailConfirmation: true, + emailSendFailed, }, { status: 201 } ); diff --git a/viewer-frontend/app/error.tsx b/viewer-frontend/app/error.tsx new file mode 100644 index 0000000..77aa86a --- /dev/null +++ b/viewer-frontend/app/error.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; + +/** + * App-level error boundary. Without this, any uncaught client render error + * (e.g. during a sign-out/session transition) produces a hard white-screen + * crash instead of a recoverable UI (issue #57). + */ +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('[APP_ERROR]', error); + }, [error]); + + return ( +
+

Something went wrong

+

+ An unexpected error occurred. You can try again, or head back to the homepage. +

+
+ + +
+
+ ); +} diff --git a/viewer-frontend/components/Header.tsx b/viewer-frontend/components/Header.tsx index f908e88..08c8c91 100644 --- a/viewer-frontend/components/Header.tsx +++ b/viewer-frontend/components/Header.tsx @@ -29,7 +29,17 @@ export function Header() { const [popoverOpen, setPopoverOpen] = useState(false); const handleSignOut = async () => { - await signOut({ callbackUrl: '/' }); + // Close any overlays tied to the authenticated state (e.g. Manage Users) + // *before* the session clears, otherwise their effects/portals can unmount + // mid-transition and throw (issue #57). + setPopoverOpen(false); + setManageUsersOpen(false); + try { + await signOut({ callbackUrl: '/' }); + } catch (error) { + console.error('[SIGN_OUT] Failed to sign out cleanly:', error); + router.push('/'); + } }; return ( diff --git a/viewer-frontend/components/RegisterDialog.tsx b/viewer-frontend/components/RegisterDialog.tsx index 09bf729..7888ae4 100644 --- a/viewer-frontend/components/RegisterDialog.tsx +++ b/viewer-frontend/components/RegisterDialog.tsx @@ -107,7 +107,11 @@ export function RegisterDialog({ setError(''); // Show success state - alert('Account created successfully! Please check your email to confirm your account before signing in.'); + alert( + data.emailSendFailed + ? 'Account created, but we could not send the confirmation email right now. Once you try to sign in, use "Resend confirmation email" to try again.' + : 'Account created successfully! Please check your email to confirm your account before signing in.' + ); onOpenChange(false); if (onOpenLogin) { diff --git a/viewer-frontend/components/UserMenu.tsx b/viewer-frontend/components/UserMenu.tsx index 6bc0506..408c31a 100644 --- a/viewer-frontend/components/UserMenu.tsx +++ b/viewer-frontend/components/UserMenu.tsx @@ -29,7 +29,17 @@ function UserMenu() { const [popoverOpen, setPopoverOpen] = useState(false); const handleSignOut = async () => { - await signOut({ callbackUrl: '/' }); + // Close any overlays tied to the authenticated state (e.g. Manage Users) + // *before* the session clears, otherwise their effects/portals can unmount + // mid-transition and throw (issue #57). + setPopoverOpen(false); + setManageUsersOpen(false); + try { + await signOut({ callbackUrl: '/' }); + } catch (error) { + console.error('[SIGN_OUT] Failed to sign out cleanly:', error); + router.push('/'); + } }; if (status === 'loading') { diff --git a/viewer-frontend/hooks/useIdleLogout.ts b/viewer-frontend/hooks/useIdleLogout.ts index 281ab73..dfa0bd9 100644 --- a/viewer-frontend/hooks/useIdleLogout.ts +++ b/viewer-frontend/hooks/useIdleLogout.ts @@ -23,7 +23,9 @@ export function useIdleLogout(timeoutMs: number = 2 * 60 * 60 * 1000, enabled: b if (session && enabled) { timerRef.current = setTimeout(() => { console.log('[IDLE_LOGOUT] User inactive for', timeoutMs / 1000 / 60, 'minutes, logging out...'); - signOut({ callbackUrl: '/' }); + signOut({ callbackUrl: '/' }).catch((error) => { + console.error('[IDLE_LOGOUT] Failed to sign out cleanly:', error); + }); }, timeoutMs); } }, [session, enabled, timeoutMs]); diff --git a/viewer-frontend/lib/email.ts b/viewer-frontend/lib/email.ts index 84984e8..7856784 100644 --- a/viewer-frontend/lib/email.ts +++ b/viewer-frontend/lib/email.ts @@ -73,6 +73,13 @@ async function sendViaSmtp(payload: EmailPayload): Promise { } async function sendViaResend(payload: EmailPayload): Promise { + if (!process.env.RESEND_API_KEY) { + throw new Error( + 'Missing Resend configuration (RESEND_API_KEY). Set SMTP_HOST/SMTP_USER/SMTP_PASS ' + + 'or RESEND_API_KEY so password reset / email verification can actually send mail.' + ); + } + const fromEmail = process.env.RESEND_FROM_EMAIL || process.env.SMTP_FROM_EMAIL || 'onboarding@resend.dev'; const fromName = process.env.RESEND_FROM_NAME || process.env.SMTP_FROM_NAME; const replyTo = process.env.RESEND_REPLY_TO || process.env.SMTP_REPLY_TO || fromEmail; From c011c8078e92c992d73ce1ba74d49d4210452138 Mon Sep 17 00:00:00 2001 From: ilia Date: Tue, 14 Jul 2026 16:24:51 -0400 Subject: [PATCH 02/19] feat: add dark mode and audit viewer-frontend accessibility 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. --- viewer-frontend/app/HomePageContent.tsx | 8 +- .../app/admin/users/ManageUsersContent.tsx | 8 +- .../app/admin/users/ManageUsersPageClient.tsx | 38 ++++----- viewer-frontend/app/error.tsx | 4 +- viewer-frontend/app/layout.tsx | 17 +++- viewer-frontend/app/login/page.tsx | 44 +++++----- viewer-frontend/app/page.tsx | 4 +- viewer-frontend/app/register/page.tsx | 18 ++-- viewer-frontend/app/reset-password/page.tsx | 43 +++++----- viewer-frontend/app/search/SearchContent.tsx | 8 +- viewer-frontend/app/search/page.tsx | 6 +- viewer-frontend/app/test-images/page.tsx | 2 +- viewer-frontend/app/upload/UploadContent.tsx | 2 +- .../app/upload/UploadPageClient.tsx | 38 ++++----- .../components/ForgotPasswordDialog.tsx | 4 +- .../components/IdentifyFaceDialog.tsx | 8 +- viewer-frontend/components/LoginDialog.tsx | 8 +- viewer-frontend/components/PageHeader.tsx | 14 ++-- viewer-frontend/components/PhotoGrid.tsx | 8 +- viewer-frontend/components/PhotoViewer.tsx | 14 +++- .../components/PhotoViewerClient.tsx | 12 ++- viewer-frontend/components/RegisterDialog.tsx | 2 +- viewer-frontend/components/SimpleHeader.tsx | 12 +-- .../components/ThemeProviderWrapper.tsx | 20 +++++ viewer-frontend/components/ThemeToggle.tsx | 83 +++++++++++++++++++ .../components/search/CollapsibleSearch.tsx | 6 +- .../components/search/PeopleFilter.tsx | 2 +- .../components/search/SearchBar.tsx | 2 +- .../components/search/TagFilter.tsx | 2 +- viewer-frontend/hooks/useFocusTrap.ts | 68 +++++++++++++++ viewer-frontend/package-lock.json | 11 +++ viewer-frontend/package.json | 1 + 32 files changed, 371 insertions(+), 146 deletions(-) create mode 100644 viewer-frontend/components/ThemeProviderWrapper.tsx create mode 100644 viewer-frontend/components/ThemeToggle.tsx create mode 100644 viewer-frontend/hooks/useFocusTrap.ts diff --git a/viewer-frontend/app/HomePageContent.tsx b/viewer-frontend/app/HomePageContent.tsx index 544d702..725787f 100644 --- a/viewer-frontend/app/HomePageContent.tsx +++ b/viewer-frontend/app/HomePageContent.tsx @@ -997,8 +997,8 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent />
{loading ? ( -
- +
+
) : ( <> @@ -1046,9 +1046,9 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent /> {/* Infinite scroll sentinel */} -
+
{loadingMore && ( - + )}
diff --git a/viewer-frontend/app/admin/users/ManageUsersContent.tsx b/viewer-frontend/app/admin/users/ManageUsersContent.tsx index 6254c67..3337fe5 100644 --- a/viewer-frontend/app/admin/users/ManageUsersContent.tsx +++ b/viewer-frontend/app/admin/users/ManageUsersContent.tsx @@ -331,13 +331,13 @@ export function ManageUsersContent() {
{error && ( -
+
{error}
)} {successMessage && ( -
+
{successMessage}
)} @@ -389,6 +389,7 @@ export function ManageUsersContent() { variant="ghost" size="sm" onClick={() => openEditDialog(user)} + aria-label={`Edit ${user.email}`} > @@ -403,6 +404,7 @@ export function ManageUsersContent() { setDeleteConfirmOpen(true); }} className="text-red-600 hover:text-red-700" + aria-label={`Deactivate ${user.email}`} > @@ -553,7 +555,7 @@ export function ManageUsersContent() {
void; @@ -30,38 +31,35 @@ export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) { const overlayContent = (
-
- {/* Close button */} -
- -
- +
{/* Header */} -
-
+
+
PunimTag -
+
+ +
-

+

Browse our photo collection

@@ -70,7 +68,7 @@ export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) {
-
+
); diff --git a/viewer-frontend/app/error.tsx b/viewer-frontend/app/error.tsx index 77aa86a..251131f 100644 --- a/viewer-frontend/app/error.tsx +++ b/viewer-frontend/app/error.tsx @@ -20,7 +20,7 @@ export default function GlobalError({ }, [error]); return ( -
+

Something went wrong

An unexpected error occurred. You can try again, or head back to the homepage. @@ -33,6 +33,6 @@ export default function GlobalError({ Go home

-
+ ); } diff --git a/viewer-frontend/app/layout.tsx b/viewer-frontend/app/layout.tsx index 952656d..f1d7b0e 100644 --- a/viewer-frontend/app/layout.tsx +++ b/viewer-frontend/app/layout.tsx @@ -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 ( - + - - {children} - + + Skip to main content + + + + {children} + + ); diff --git a/viewer-frontend/app/login/page.tsx b/viewer-frontend/app/login/page.tsx index e9e605b..8769abe 100644 --- a/viewer-frontend/app/login/page.tsx +++ b/viewer-frontend/app/login/page.tsx @@ -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 ( -
+
+
-

+

Sign in to your account -

-

+ +

Or{' '}

{registered && ( -
-

+

+

Account created successfully! Please check your email to confirm your account before signing in.

)} {verified && ( -
-

+

+

Email verified successfully! You can now sign in.

)} {passwordReset && ( -
-

+

+

Password reset successfully! You can now sign in with your new password.

)} {emailNotVerified && ( -
-

+

+

Please verify your email address before signing in. Check your inbox for a confirmation email.

)} {error && ( -
-

{error}

+
+

{error}

)}
@@ -209,23 +211,23 @@ function LoginForm() {
-
+
); } export default function LoginPage() { return ( +
-

+

Sign in to your account -

-

Loading...

+ +

Loading...

-
+ }> diff --git a/viewer-frontend/app/page.tsx b/viewer-frontend/app/page.tsx index 03e02a7..4a0acce 100644 --- a/viewer-frontend/app/page.tsx +++ b/viewer-frontend/app/page.tsx @@ -222,10 +222,10 @@ export default async function HomePage() { ]); return ( -
+
{error ? ( -
+

Error loading photos

{error}

diff --git a/viewer-frontend/app/register/page.tsx b/viewer-frontend/app/register/page.tsx index 8f64cd9..cde8d8f 100644 --- a/viewer-frontend/app/register/page.tsx +++ b/viewer-frontend/app/register/page.tsx @@ -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 ( -

+
+
-

+

Create your account -

-

+ +

Or{' '}

{error && ( -
-

{error}

+
+

{error}

)}
@@ -144,7 +146,7 @@ export default function RegisterPage() { className="mt-1" placeholder="••••••••" /> -

+

Must be at least 6 characters

@@ -177,7 +179,7 @@ export default function RegisterPage() {
-
+
); } diff --git a/viewer-frontend/app/reset-password/page.tsx b/viewer-frontend/app/reset-password/page.tsx index 65b39da..6afafdd 100644 --- a/viewer-frontend/app/reset-password/page.tsx +++ b/viewer-frontend/app/reset-password/page.tsx @@ -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 ( -
+
+
-

+

Password reset successful -

-

+ +

Your password has been reset successfully. Redirecting to login...

-
-

+

+

You can now sign in with your new password.

@@ -95,25 +97,26 @@ function ResetPasswordForm() {
-
+
); } return ( -
+
+
-

+

Reset your password -

-

+ +

Enter your new password below

{error && ( -
-

{error}

+
+

{error}

)}
@@ -132,7 +135,7 @@ function ResetPasswordForm() { className="mt-1" placeholder="••••••••" /> -

+

Must be at least 6 characters

@@ -173,23 +176,23 @@ function ResetPasswordForm() {
-
+
); } export default function ResetPasswordPage() { return ( +
-

+

Reset your password -

-

Loading...

+ +

Loading...

-
+ }> diff --git a/viewer-frontend/app/search/SearchContent.tsx b/viewer-frontend/app/search/SearchContent.tsx index edd02a6..05d755e 100644 --- a/viewer-frontend/app/search/SearchContent.tsx +++ b/viewer-frontend/app/search/SearchContent.tsx @@ -146,8 +146,8 @@ export function SearchContent({ people, tags }: SearchContentProps) { {/* Results */}
{loading ? ( -
- +
+
) : ( <> @@ -192,11 +192,11 @@ export function SearchContent({ people, tags }: SearchContentProps) { ) : hasActiveFilters ? (
-

No photos found matching your filters

+

No photos found matching your filters

) : (
-

Select filters to search photos

+

Select filters to search photos

)} diff --git a/viewer-frontend/app/search/page.tsx b/viewer-frontend/app/search/page.tsx index 379cddd..dda07c2 100644 --- a/viewer-frontend/app/search/page.tsx +++ b/viewer-frontend/app/search/page.tsx @@ -97,7 +97,7 @@ export default async function SearchPage() { ]); return ( -
+

Search Photos @@ -108,8 +108,8 @@ export default async function SearchPage() {

-
Loading search...
+
+
Loading search...
}> diff --git a/viewer-frontend/app/test-images/page.tsx b/viewer-frontend/app/test-images/page.tsx index 4516386..2a1683f 100644 --- a/viewer-frontend/app/test-images/page.tsx +++ b/viewer-frontend/app/test-images/page.tsx @@ -48,7 +48,7 @@ export default function TestImagesPage() { ]; return ( -
+

Image Source Test Page diff --git a/viewer-frontend/app/upload/UploadContent.tsx b/viewer-frontend/app/upload/UploadContent.tsx index edc8adb..dc3460c 100644 --- a/viewer-frontend/app/upload/UploadContent.tsx +++ b/viewer-frontend/app/upload/UploadContent.tsx @@ -350,7 +350,7 @@ export function UploadContent() {

{allSuccess && ( -
+

diff --git a/viewer-frontend/app/upload/UploadPageClient.tsx b/viewer-frontend/app/upload/UploadPageClient.tsx index fd40515..ab0a39a 100644 --- a/viewer-frontend/app/upload/UploadPageClient.tsx +++ b/viewer-frontend/app/upload/UploadPageClient.tsx @@ -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 (

-
- {/* Close button */} -
- -
- +
{/* Header */} -
-
+
+
PunimTag -
+
+ +
-

+

Browse our photo collection

@@ -65,7 +63,7 @@ export function UploadPageClient() {
-
+
); } diff --git a/viewer-frontend/components/ForgotPasswordDialog.tsx b/viewer-frontend/components/ForgotPasswordDialog.tsx index 45babe1..596d815 100644 --- a/viewer-frontend/components/ForgotPasswordDialog.tsx +++ b/viewer-frontend/components/ForgotPasswordDialog.tsx @@ -91,7 +91,7 @@ export function ForgotPasswordDialog({ {success ? (
-
+

Password reset email sent! Please check your inbox and follow the instructions to reset your password.

@@ -105,7 +105,7 @@ export function ForgotPasswordDialog({ ) : (
{error && ( -
+

{error}

)} diff --git a/viewer-frontend/components/IdentifyFaceDialog.tsx b/viewer-frontend/components/IdentifyFaceDialog.tsx index f4f9acd..5bc6975 100644 --- a/viewer-frontend/components/IdentifyFaceDialog.tsx +++ b/viewer-frontend/components/IdentifyFaceDialog.tsx @@ -273,7 +273,7 @@ export function IdentifyFaceDialog({
-

+

Please sign in or create an account to continue.

@@ -363,7 +363,7 @@ export function IdentifyFaceDialog({
-

+

Only users with write access can identify faces. Please contact an administrator to request write access.

@@ -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 ? ( -

No people found

+

No people found

) : (
{people @@ -512,7 +512,7 @@ export function IdentifyFaceDialog({ {person.firstName} {person.lastName}
{(person.middleName || person.maidenName) && ( -
+
{[person.middleName, person.maidenName].filter(Boolean).join(' • ')}
)} diff --git a/viewer-frontend/components/LoginDialog.tsx b/viewer-frontend/components/LoginDialog.tsx index 0604613..1fc5925 100644 --- a/viewer-frontend/components/LoginDialog.tsx +++ b/viewer-frontend/components/LoginDialog.tsx @@ -185,21 +185,21 @@ export function LoginDialog({ {registered && ( -
+

Account created successfully! Please check your email to confirm your account before signing in.

)} {searchParams.get('verified') === 'true' && ( -
+

Email verified successfully! You can now sign in.

)} {emailNotVerified && ( -
+

Please verify your email address before signing in. Check your inbox for a confirmation email.

@@ -214,7 +214,7 @@ export function LoginDialog({
)} {error && ( -
+

{error}

)} diff --git a/viewer-frontend/components/PageHeader.tsx b/viewer-frontend/components/PageHeader.tsx index cdc210a..b948490 100644 --- a/viewer-frontend/components/PageHeader.tsx +++ b/viewer-frontend/components/PageHeader.tsx @@ -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 ( -
-
+
+
PunimTag -
+
+
-

+

Browse our photo collection -

+

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..." /> -
+
{`${reportDialogComment.length}/${REPORT_COMMENT_MAX_LENGTH} characters`} - {reportDialogError && {reportDialogError}} + {reportDialogError && {reportDialogError}}
@@ -836,7 +836,7 @@ export function PhotoGrid({
-

+

Please sign in or create an account to continue.

diff --git a/viewer-frontend/components/PhotoViewer.tsx b/viewer-frontend/components/PhotoViewer.tsx index 6c19370..29fae0a 100644 --- a/viewer-frontend/components/PhotoViewer.tsx +++ b/viewer-frontend/components/PhotoViewer.tsx @@ -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(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 ( -
+
{/* Close Button */} + ); + } + + const isDark = resolvedTheme === 'dark'; + const label = isDark ? 'Switch to light mode' : 'Switch to dark mode'; + + return ( + + + + + +

{label}

+
+
+ ); +} diff --git a/viewer-frontend/components/search/CollapsibleSearch.tsx b/viewer-frontend/components/search/CollapsibleSearch.tsx index e556892..a74e146 100644 --- a/viewer-frontend/components/search/CollapsibleSearch.tsx +++ b/viewer-frontend/components/search/CollapsibleSearch.tsx @@ -36,7 +36,7 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co {isExpanded ? ( <>
- +