Fix favorite sign-in copy + text contrast #88
@@ -98,6 +98,11 @@ Living plan for product quality, auth/email reliability, and automation.
|
||||
- [x] **Core Web Vitals budget** — LCP + CLS gates on home gallery (`viewer.web-vitals.spec.ts`, `web-vitals-budgets.ts`)
|
||||
- [x] **Unify `/` and `/search`** — `/search` redirects to `/?…` with full query string (`peopleMode`, `tagsMode`, etc.)
|
||||
|
||||
#### UX polish (2026-08)
|
||||
|
||||
- [x] **Favorite sign-in copy** — logged-out heart opens favorites messaging (not report) via `SignInRequiredDialog`
|
||||
- [x] **Text contrast** — replace misplaced `text-secondary` (pale wash token) with `text-foreground` / `text-primary` on labels, auth dialogs, menus
|
||||
|
||||
## Later
|
||||
|
||||
- [x] Proper DEV deploy (`next start` + CI image) instead of long-lived `next dev` — `scripts/deploy-viewer.sh`, `ecosystem.config.js.example` uses `next start -p 3001`
|
||||
|
||||
@@ -70,6 +70,7 @@ not enabled in this CI job yet.
|
||||
23. Core Web Vitals budget (`viewer.web-vitals.spec.ts`, `web-vitals-budgets.ts`): LCP + CLS gates on anonymous home gallery load
|
||||
24. Admin review pages smoke (`admin.review-pages.spec.ts`): FastAPI admin login on `punimtagadmindev` → Identify, Auto-Match, Approve Identified load
|
||||
25. `/search` legacy URLs redirect to `/` with query string preserved (incl. `peopleMode` / `tagsMode`)
|
||||
26. Logged-out favorite (`viewer.favorite-signin.spec.ts`): Sign In Required dialog uses favorites copy, not report copy
|
||||
|
||||
See repo root [`ROADMAP.md`](../ROADMAP.md) for gaps and next steps.
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
test.describe('favorite sign-in copy @smoke', () => {
|
||||
test('logged-out favorite opens sign-in dialog with favorites messaging', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
}) => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Sign in/i }).or(page.getByLabel('Account menu')).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Open photo viewer (matches the reported bug path) then click the heart.
|
||||
const photoButton = page.locator('main .aspect-square button').first();
|
||||
await expect(photoButton).toBeVisible({ timeout: 15_000 });
|
||||
await photoButton.click();
|
||||
const viewer = page.getByRole('dialog', { name: /Photo viewer/i });
|
||||
await expect(viewer).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await viewer.getByRole('button', { name: 'Add to favorites' }).click();
|
||||
|
||||
const signInDialog = page.getByRole('dialog').filter({ hasText: 'Sign In Required' });
|
||||
await expect(signInDialog).toBeVisible({ timeout: 10_000 });
|
||||
await expect(signInDialog).toContainText(/save favorites|favorite photos/i);
|
||||
await expect(signInDialog).not.toContainText(/report photos/i);
|
||||
});
|
||||
});
|
||||
@@ -1094,7 +1094,7 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="p-2 text-secondary hover:text-secondary/80 hover:bg-secondary/10 rounded transition-colors"
|
||||
className="p-2 text-primary hover:text-primary/80 hover:bg-secondary/10 rounded transition-colors"
|
||||
title="Select all photos"
|
||||
aria-label="Select all photos"
|
||||
>
|
||||
@@ -1102,7 +1102,7 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="p-2 text-secondary hover:text-secondary/80 hover:bg-secondary/10 rounded transition-colors"
|
||||
className="p-2 text-primary hover:text-primary/80 hover:bg-secondary/10 rounded transition-colors"
|
||||
title="Clear selection"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function GlobalError({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h1 className="text-xl font-semibold text-foreground">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.
|
||||
</p>
|
||||
|
||||
@@ -104,7 +104,7 @@ function LoginForm() {
|
||||
<ThemeToggle className="absolute right-4 top-4" />
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<h1 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-foreground">
|
||||
Sign in to your account
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -161,7 +161,7 @@ function LoginForm() {
|
||||
)}
|
||||
<div className="space-y-4 rounded-md shadow-sm">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-secondary">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<Input
|
||||
@@ -183,7 +183,7 @@ function LoginForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
@@ -221,7 +221,7 @@ export default function LoginPage() {
|
||||
<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">
|
||||
<h1 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-foreground">
|
||||
Sign in to your account
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function RegisterPage() {
|
||||
<ThemeToggle className="absolute right-4 top-4" />
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<h1 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-foreground">
|
||||
Create your account
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -100,7 +100,7 @@ export default function RegisterPage() {
|
||||
)}
|
||||
<div className="space-y-4 rounded-md shadow-sm">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-secondary">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-foreground">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -116,7 +116,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-secondary">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -132,7 +132,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
@@ -151,7 +151,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary">
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
|
||||
@@ -76,7 +76,7 @@ function ResetPasswordForm() {
|
||||
<ThemeToggle className="absolute right-4 top-4" />
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<h1 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-foreground">
|
||||
Password reset successful
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -106,7 +106,7 @@ function ResetPasswordForm() {
|
||||
<ThemeToggle className="absolute right-4 top-4" />
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<h1 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-foreground">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -121,7 +121,7 @@ function ResetPasswordForm() {
|
||||
)}
|
||||
<div className="space-y-4 rounded-md shadow-sm">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
@@ -140,7 +140,7 @@ function ResetPasswordForm() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary">
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
@@ -186,7 +186,7 @@ export default function ResetPasswordPage() {
|
||||
<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">
|
||||
<h1 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-foreground">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
|
||||
@@ -290,7 +290,7 @@ export function UploadContent() {
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-lg font-medium text-secondary dark:text-gray-50">
|
||||
<span className="text-lg font-medium text-foreground dark:text-gray-50">
|
||||
Drop photos and videos here or click to browse
|
||||
</span>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
@@ -315,7 +315,7 @@ export function UploadContent() {
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-secondary dark:text-gray-50">
|
||||
<h2 className="text-lg font-semibold text-foreground dark:text-gray-50">
|
||||
Selected Files ({files.length})
|
||||
</h2>
|
||||
{!allSuccess && (
|
||||
|
||||
@@ -47,7 +47,7 @@ export function UploadPageClient() {
|
||||
{/* Upload content */}
|
||||
<div className="mt-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
|
||||
<h1 className="text-4xl font-bold text-foreground dark:text-gray-50">
|
||||
Upload Photos & Videos
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ForgotPasswordDialog({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="forgot-email" className="block text-sm font-medium text-secondary dark:text-gray-300">
|
||||
<label htmlFor="forgot-email" className="block text-sm font-medium text-foreground dark:text-gray-300">
|
||||
Email address
|
||||
</label>
|
||||
<Input
|
||||
|
||||
@@ -103,7 +103,7 @@ export function Header() {
|
||||
<PopoverContent className="w-56 p-2" align="end">
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="text-sm font-medium text-secondary">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{session.user.name || 'User'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -113,7 +113,7 @@ export function Header() {
|
||||
<div className="border-t pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
|
||||
className="w-full justify-start text-sm text-primary hover:text-primary/80 hover:bg-secondary/10"
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
router.push('/upload');
|
||||
@@ -125,7 +125,7 @@ export function Header() {
|
||||
{session.user.isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
|
||||
className="w-full justify-start text-sm text-primary hover:text-primary/80 hover:bg-secondary/10"
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
setManageUsersOpen(true);
|
||||
@@ -137,7 +137,7 @@ export function Header() {
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
|
||||
className="w-full justify-start text-sm text-primary hover:text-primary/80 hover:bg-secondary/10"
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
handleSignOut();
|
||||
|
||||
@@ -164,7 +164,7 @@ export function LoginDialog({
|
||||
{onOpenRegister ? (
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-secondary hover:text-secondary/80"
|
||||
className="font-medium text-primary hover:text-primary/80"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenRegister();
|
||||
@@ -175,7 +175,7 @@ export function LoginDialog({
|
||||
) : (
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-secondary hover:text-secondary/80"
|
||||
className="font-medium text-primary hover:text-primary/80"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
create a new account
|
||||
@@ -220,7 +220,7 @@ export function LoginDialog({
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-secondary dark:text-gray-300">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground dark:text-gray-300">
|
||||
Email address
|
||||
</label>
|
||||
<Input
|
||||
@@ -243,7 +243,7 @@ export function LoginDialog({
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary dark:text-gray-300">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<button
|
||||
@@ -251,7 +251,7 @@ export function LoginDialog({
|
||||
onClick={() => {
|
||||
setForgotPasswordOpen(true);
|
||||
}}
|
||||
className="text-sm text-secondary hover:text-secondary/80 font-medium"
|
||||
className="text-sm text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
@@ -271,7 +271,7 @@ export function LoginDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-primary hover:text-primary/80 focus:outline-none"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
|
||||
@@ -25,6 +25,10 @@ import { parseFaceLocation, isPointInFace } from '@/lib/face-utils';
|
||||
import { isUrl, isVideo, getImageSrc } from '@/lib/photo-utils';
|
||||
import { LoginDialog } from '@/components/LoginDialog';
|
||||
import { RegisterDialog } from '@/components/RegisterDialog';
|
||||
import {
|
||||
SignInRequiredDialog,
|
||||
type SignInRequiredReason,
|
||||
} from '@/components/SignInRequiredDialog';
|
||||
|
||||
interface FaceWithLocation {
|
||||
id: number;
|
||||
@@ -152,7 +156,8 @@ export function PhotoGrid({
|
||||
const [reportedPhotos, setReportedPhotos] = useState<Map<number, { status: string }>>(new Map());
|
||||
const [favoritingPhotoId, setFavoritingPhotoId] = useState<number | null>(null);
|
||||
const [favoritedPhotos, setFavoritedPhotos] = useState<Map<number, boolean>>(new Map());
|
||||
const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false);
|
||||
const [signInRequiredReason, setSignInRequiredReason] =
|
||||
useState<SignInRequiredReason | null>(null);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
|
||||
@@ -457,7 +462,7 @@ export function PhotoGrid({
|
||||
e.stopPropagation(); // Prevent photo click from firing
|
||||
|
||||
if (!session) {
|
||||
setShowSignInRequiredDialog(true);
|
||||
setSignInRequiredReason('report');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -510,7 +515,7 @@ export function PhotoGrid({
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
if (response.status === 401) {
|
||||
setShowSignInRequiredDialog(true);
|
||||
setSignInRequiredReason('report');
|
||||
} else if (response.status === 403) {
|
||||
alert(error?.error || 'Cannot re-report this photo.');
|
||||
} else if (response.status === 409) {
|
||||
@@ -547,7 +552,7 @@ export function PhotoGrid({
|
||||
e.stopPropagation(); // Prevent photo click from firing
|
||||
|
||||
if (!session) {
|
||||
setShowSignInRequiredDialog(true);
|
||||
setSignInRequiredReason('favorite');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -566,7 +571,7 @@ export function PhotoGrid({
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
if (response.status === 401) {
|
||||
setShowSignInRequiredDialog(true);
|
||||
setSignInRequiredReason('favorite');
|
||||
} else {
|
||||
alert(error.error || 'Failed to toggle favorite');
|
||||
}
|
||||
@@ -642,7 +647,7 @@ export function PhotoGrid({
|
||||
{isVideoPhoto && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
|
||||
<div className="rounded-full bg-white/90 p-3 shadow-lg group-hover:bg-white transition-colors">
|
||||
<Play className="h-6 w-6 text-secondary fill-secondary ml-1" />
|
||||
<Play className="h-6 w-6 text-foreground fill-foreground ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -794,7 +799,7 @@ export function PhotoGrid({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label htmlFor="report-comment" className="text-sm font-medium text-secondary">
|
||||
<label htmlFor="report-comment" className="text-sm font-medium text-foreground">
|
||||
Comment (optional)
|
||||
</label>
|
||||
<textarea
|
||||
@@ -802,7 +807,7 @@ 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 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"
|
||||
className="mt-2 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-foreground 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..."
|
||||
/>
|
||||
@@ -834,46 +839,15 @@ export function PhotoGrid({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Sign In Required Dialog for Report */}
|
||||
<Dialog open={showSignInRequiredDialog} onOpenChange={setShowSignInRequiredDialog}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign In Required</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to be signed in to report photos. Your reports will be reviewed by administrators.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-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">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoginDialogOpen(true);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRegisterDialogOpen(true);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSignInRequiredDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SignInRequiredDialog
|
||||
open={signInRequiredReason !== null}
|
||||
reason={signInRequiredReason ?? 'favorite'}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSignInRequiredReason(null);
|
||||
}}
|
||||
onSignIn={() => setLoginDialogOpen(true)}
|
||||
onRegister={() => setRegisterDialogOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Login Dialog */}
|
||||
<LoginDialog
|
||||
@@ -887,7 +861,7 @@ export function PhotoGrid({
|
||||
onSuccess={async () => {
|
||||
await update();
|
||||
router.refresh();
|
||||
setShowSignInRequiredDialog(false);
|
||||
setSignInRequiredReason(null);
|
||||
}}
|
||||
onOpenRegister={() => {
|
||||
setLoginDialogOpen(false);
|
||||
@@ -909,7 +883,7 @@ export function PhotoGrid({
|
||||
onSuccess={async () => {
|
||||
await update();
|
||||
router.refresh();
|
||||
setShowSignInRequiredDialog(false);
|
||||
setSignInRequiredReason(null);
|
||||
}}
|
||||
onOpenLogin={() => {
|
||||
setShowRegisteredMessage(true);
|
||||
|
||||
@@ -22,6 +22,10 @@ import { isUrl, isVideo, getImageSrc, getVideoSrc, getWebPlaybackStreamUrl } fro
|
||||
import { IdentifyFaceDialog } from '@/components/IdentifyFaceDialog';
|
||||
import { LoginDialog } from '@/components/LoginDialog';
|
||||
import { RegisterDialog } from '@/components/RegisterDialog';
|
||||
import {
|
||||
SignInRequiredDialog,
|
||||
type SignInRequiredReason,
|
||||
} from '@/components/SignInRequiredDialog';
|
||||
|
||||
interface FaceWithLocation {
|
||||
id: number;
|
||||
@@ -172,7 +176,8 @@ export function PhotoViewerClient({
|
||||
const [reportDialogError, setReportDialogError] = useState<string | null>(null);
|
||||
const [favoritingPhotoId, setFavoritingPhotoId] = useState<number | null>(null);
|
||||
const [isFavorited, setIsFavorited] = useState<boolean>(false);
|
||||
const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false);
|
||||
const [signInRequiredReason, setSignInRequiredReason] =
|
||||
useState<SignInRequiredReason | null>(null);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
|
||||
@@ -1071,7 +1076,7 @@ export function PhotoViewerClient({
|
||||
const handleReportPhoto = async () => {
|
||||
// Check if user is logged in
|
||||
if (!session) {
|
||||
setShowSignInRequiredDialog(true);
|
||||
setSignInRequiredReason('report');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1124,7 +1129,7 @@ export function PhotoViewerClient({
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
if (response.status === 401) {
|
||||
setShowSignInRequiredDialog(true);
|
||||
setSignInRequiredReason('report');
|
||||
} else if (response.status === 403) {
|
||||
alert(error?.error || 'Cannot re-report this photo.');
|
||||
} else if (response.status === 409) {
|
||||
@@ -1159,7 +1164,7 @@ export function PhotoViewerClient({
|
||||
const handleToggleFavorite = async () => {
|
||||
// Check if user is logged in
|
||||
if (!session) {
|
||||
setShowSignInRequiredDialog(true);
|
||||
setSignInRequiredReason('favorite');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1178,7 +1183,7 @@ export function PhotoViewerClient({
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
if (response.status === 401) {
|
||||
setShowSignInRequiredDialog(true);
|
||||
setSignInRequiredReason('favorite');
|
||||
} else {
|
||||
alert(error.error || 'Failed to toggle favorite');
|
||||
}
|
||||
@@ -1503,7 +1508,7 @@ export function PhotoViewerClient({
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<div className="bg-white text-secondary rounded-md px-3 py-1.5 shadow-lg border border-gray-200">
|
||||
<div className="bg-white text-foreground rounded-md px-3 py-1.5 shadow-lg border border-gray-200">
|
||||
<p className="text-sm font-medium whitespace-nowrap">
|
||||
{hoveredFaceTooltip}
|
||||
</p>
|
||||
@@ -1712,7 +1717,7 @@ export function PhotoViewerClient({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label htmlFor="report-comment" className="text-sm font-medium text-secondary">
|
||||
<label htmlFor="report-comment" className="text-sm font-medium text-foreground">
|
||||
Comment (optional)
|
||||
</label>
|
||||
<textarea
|
||||
@@ -1752,46 +1757,15 @@ export function PhotoViewerClient({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Sign In Required Dialog for Report */}
|
||||
<Dialog open={showSignInRequiredDialog} onOpenChange={setShowSignInRequiredDialog}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign In Required</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to be signed in to report photos. Your reports will be reviewed by administrators.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Please sign in or create an account to continue.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoginDialogOpen(true);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRegisterDialogOpen(true);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSignInRequiredDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SignInRequiredDialog
|
||||
open={signInRequiredReason !== null}
|
||||
reason={signInRequiredReason ?? 'favorite'}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSignInRequiredReason(null);
|
||||
}}
|
||||
onSignIn={() => setLoginDialogOpen(true)}
|
||||
onRegister={() => setRegisterDialogOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Login Dialog */}
|
||||
<LoginDialog
|
||||
@@ -1805,7 +1779,7 @@ export function PhotoViewerClient({
|
||||
onSuccess={async () => {
|
||||
await update();
|
||||
router.refresh();
|
||||
setShowSignInRequiredDialog(false);
|
||||
setSignInRequiredReason(null);
|
||||
}}
|
||||
onOpenRegister={() => {
|
||||
setLoginDialogOpen(false);
|
||||
@@ -1827,7 +1801,7 @@ export function PhotoViewerClient({
|
||||
onSuccess={async () => {
|
||||
await update();
|
||||
router.refresh();
|
||||
setShowSignInRequiredDialog(false);
|
||||
setSignInRequiredReason(null);
|
||||
}}
|
||||
onOpenLogin={() => {
|
||||
setShowRegisteredMessage(true);
|
||||
|
||||
@@ -153,7 +153,7 @@ export function RegisterDialog({
|
||||
Or{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-secondary hover:text-secondary/80"
|
||||
className="font-medium text-primary hover:text-primary/80"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
if (onOpenLogin) {
|
||||
@@ -173,7 +173,7 @@ export function RegisterDialog({
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-secondary dark:text-gray-300">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-foreground dark:text-gray-300">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -189,7 +189,7 @@ export function RegisterDialog({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-secondary dark:text-gray-300">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground dark:text-gray-300">
|
||||
Email address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -205,7 +205,7 @@ export function RegisterDialog({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary dark:text-gray-300">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
@@ -223,7 +223,7 @@ export function RegisterDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-primary hover:text-primary/80 focus:outline-none"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
@@ -238,7 +238,7 @@ export function RegisterDialog({
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary dark:text-gray-300">
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground dark:text-gray-300">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
@@ -256,7 +256,7 @@ export function RegisterDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-primary hover:text-primary/80 focus:outline-none"
|
||||
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export type SignInRequiredReason = 'favorite' | 'report';
|
||||
|
||||
const COPY: Record<
|
||||
SignInRequiredReason,
|
||||
{ description: string; detail: string }
|
||||
> = {
|
||||
favorite: {
|
||||
description:
|
||||
'You need to be signed in to save favorites. Favorites stay with your account so you can find them later.',
|
||||
detail: 'Please sign in or create an account to favorite photos.',
|
||||
},
|
||||
report: {
|
||||
description:
|
||||
'You need to be signed in to report photos. Your reports will be reviewed by administrators.',
|
||||
detail: 'Please sign in or create an account to continue.',
|
||||
},
|
||||
};
|
||||
|
||||
type SignInRequiredDialogProps = {
|
||||
open: boolean;
|
||||
reason: SignInRequiredReason;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSignIn: () => void;
|
||||
onRegister: () => void;
|
||||
};
|
||||
|
||||
export function SignInRequiredDialog({
|
||||
open,
|
||||
reason,
|
||||
onOpenChange,
|
||||
onSignIn,
|
||||
onRegister,
|
||||
}: SignInRequiredDialogProps) {
|
||||
const copy = COPY[reason];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign In Required</DialogTitle>
|
||||
<DialogDescription>{copy.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="mb-4 text-sm text-muted-foreground">{copy.detail}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSignIn();
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onRegister();
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ function UserMenu() {
|
||||
<PopoverContent className="w-56 p-2 z-[110]" align="end">
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="text-sm font-medium text-secondary">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{session.user.name || 'User'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -90,7 +90,7 @@ function UserMenu() {
|
||||
<div className="border-t pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
|
||||
className="w-full justify-start text-sm text-primary hover:text-primary/80 hover:bg-secondary/10"
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
router.push('/upload');
|
||||
@@ -102,7 +102,7 @@ function UserMenu() {
|
||||
{session.user.isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
|
||||
className="w-full justify-start text-sm text-primary hover:text-primary/80 hover:bg-secondary/10"
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
setManageUsersOpen(true);
|
||||
@@ -114,7 +114,7 @@ function UserMenu() {
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
|
||||
className="w-full justify-start text-sm text-primary hover:text-primary/80 hover:bg-secondary/10"
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
handleSignOut();
|
||||
|
||||
Reference in New Issue
Block a user