+
{sparringSessions.map((session) => (
@@ -342,7 +360,7 @@ export default function HistoryPage() {
))}
-
+
)}
@@ -359,7 +377,7 @@ export default function HistoryPage() {
{t('common.cancel', 'Cancel')}
- {t('common.delete', 'Delete')}
+ {t('common.delete', 'Delete')}
@@ -373,7 +391,7 @@ export default function HistoryPage() {
{t('common.cancel', 'Cancel')}
- {t('common.delete', 'Delete')}
+ {t('common.delete', 'Delete')}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index aaf5d77a2..eae56ef3e 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -1,104 +1,23 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import {
- ArrowRight,
- BookOpen,
- Clock,
- Crosshair,
- Database,
- Dumbbell,
- GitBranch,
- Lightbulb,
- MessageCircle,
- Sparkles,
- Swords,
- Timer,
- type LucideIcon,
-} from 'lucide-react';
+import { ArrowRight } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
-import { useRoutePrefetch } from '@/hooks/use-route-prefetch';
-import { cn } from '@/lib/utils';
+import { PageHeader } from '@/components/layout/PageHeader';
+import { PRACTICE_TOOLS, PREPARE_TOOLS, ToolLink, UTILITY_TOOLS, type Tool } from '@/components/features/tools/tool-links';
import { formatRelativeDate } from '@/lib/date-format';
import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store';
import { useDebateCounts } from '@/components/features/debate/CurrentDebateStrip';
-type Tool = {
- key: string;
- href: string;
- icon: LucideIcon;
-};
-
-const prepareTools = [
- { key: 'generate', href: '/app/generate', icon: Sparkles },
- { key: 'brainstorm', href: '/app/brainstorm', icon: Lightbulb },
- { key: 'factoids', href: '/app/factoids', icon: BookOpen },
- { key: 'flowchart', href: '/app/flowchart', icon: GitBranch },
-] satisfies readonly Tool[];
-
-const practiceTools = [
- { key: 'attack', href: '/app/attack', icon: Swords },
- { key: 'ask', href: '/app/ask', icon: MessageCircle },
- { key: 'crossfire', href: '/app/crossfire', icon: Crosshair },
- { key: 'sparring', href: '/app/sparring', icon: Dumbbell },
-] satisfies readonly Tool[];
-
-const utilityTools = [
- { key: 'library', href: '/app/library', icon: Database },
- { key: 'timer', href: '/app/timer', icon: Timer },
- { key: 'history', href: '/app/history', icon: Clock },
-] satisfies readonly Tool[];
-
-function ToolRow({ tool, t }: { tool: Tool; t: TFunction }) {
- const Icon = tool.icon;
- const prefetchHandlers = useRoutePrefetch(tool.href);
- return (
-
-
-
-
-
- {t(`home.${tool.key}`, tool.key)}
- {t(`home.${tool.key}Desc`, '')}
-
-
-
- );
-}
-
-function UtilityToolLink({ tool, t }: { tool: Tool; t: TFunction }) {
- const Icon = tool.icon;
- const prefetchHandlers = useRoutePrefetch(tool.href);
- return (
-
-
- {t(`home.${tool.key}`, tool.key)}
-
- );
-}
-
-function ToolGroup({ title, tools, t }: { title: string; tools: readonly Tool[]; t: TFunction }) {
+function ToolGroup({ title, tools }: { title: string; tools: readonly Tool[] }) {
return (
- {title}
+ {title}
- {tools.map((tool) => )}
+ {tools.map((tool) => )}
);
@@ -151,21 +70,19 @@ export default function HomePage() {
return (
-
-
{t('home.title')}
-
+
-
-
+
+
{t('home.utilities', 'Utilities')}
- {utilityTools.map((tool) => )}
+ {UTILITY_TOOLS.map((tool) => )}
diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx
index 5389e4b9b..9853279c4 100644
--- a/src/pages/LibraryPage.tsx
+++ b/src/pages/LibraryPage.tsx
@@ -1,8 +1,8 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSessionStore, type Source } from '@/lib/stores/session-store';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Button } from '@/components/ui/button';
+import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -36,8 +36,6 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
-const SOURCE_LIST_HEIGHT = 600;
-
interface SourceRowProps {
source: Source;
onRemove: (source: Source) => void;
@@ -66,84 +64,93 @@ function SourceRow({
return (
+ {/* The row is clickable for pointers; the title button is the keyboard and screen-reader target. */}
onOpen(source)}
- onKeyDown={(event) => {
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- onOpen(source);
- }
- }}
- className={`flex h-[84px] cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors motion-list-item hover:border-primary/40 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${source.isActive ? 'bg-card' : 'bg-muted/30 opacity-60'}`}
+ className={`flex min-h-[84px] cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors motion-list-item hover:border-primary/40 hover:bg-accent/40 ${source.isActive ? 'bg-card' : 'bg-muted/30 opacity-60'}`}
>
-
+
{source.type === 'url' ? : }
-
{formatEuropeanDate(source.createdAt)} • {source.origin || manualEntryLabel}
-
+
{formatEuropeanDate(source.createdAt)} • {source.origin || manualEntryLabel}
+ {/* Badges wrap on narrow rows; the text preview only fits beside them on wider screens. */}
+
{sourceFolderLabel}
{source.metadata?.semantic?.lastUsedInCase && (
-
+
{t('sourceQuality.usedInCase')}
)}
{source.metadata?.semantic && (
-
+
{t('sourceQuality.importance', { level: t(`sourceQuality.levels.${source.metadata.semantic.importance}`) })}
)}
{source.metadata?.semantic && (
-
+
{t('sourceQuality.strength', { level: t(`sourceQuality.levels.${source.metadata.semantic.strength}`) })}
)}
- {source.content}
+ {source.content}
-
+
{
event.stopPropagation();
onRemove(source);
}}
aria-label={t('libraryPage.deleteSourceLabel', { title: source.title })}
>
-
+
-
event.stopPropagation()}
- onCheckedChange={() => onToggle(source.id)}
- aria-label={t('libraryPage.toggleSourceLabel', { title: source.title })}
- />
+ event.stopPropagation()}>
+ onToggle(source.id)}
+ aria-label={t('libraryPage.toggleSourceLabel', { title: source.title })}
+ />
+
@@ -178,6 +185,16 @@ export default function LibraryPage() {
const [isAdding, setIsAdding] = useState(false);
const [isClearing, setIsClearing] = useState(false);
+ const [isImportingFile, setIsImportingFile] = useState(false);
+ const [addErrors, setAddErrors] = useState<{ title?: string; content?: string }>({});
+ const [draftErrors, setDraftErrors] = useState<{ title?: string; content?: string }>({});
+ const [mergeTarget, setMergeTarget] = useState
(null);
+ const [merging, setMerging] = useState(false);
+ const [mergeError, setMergeError] = useState(false);
+ const addTitleRef = useRef(null);
+ const addContentRef = useRef(null);
+ const draftTitleRef = useRef(null);
+ const draftContentRef = useRef(null);
const [newSource, setNewSource] = useState({
title: '',
content: '',
@@ -214,16 +231,21 @@ export default function LibraryPage() {
warnings?: string[];
} | null>(null);
+ // Required fields are checked inline: the message sits under the field and focus moves to it.
+ const requiredErrors = (title: string, content: string) => ({
+ title: title.trim() ? undefined : t('libraryPage.titleRequired', 'Add a title.'),
+ content: content.trim() ? undefined : t('libraryPage.contentRequired', 'Add the source text.'),
+ });
+
const handleAdd = async () => {
- if (!newSource.title || !newSource.content) {
- toast({ title: t('common.error'), description: t('libraryPage.errorRequired'), variant: "destructive" });
- return;
- }
+ const errors = requiredErrors(newSource.title, newSource.content);
+ setAddErrors(errors);
+ if (errors.title) return addTitleRef.current?.focus();
+ if (errors.content) return addContentRef.current?.focus();
// The Library always shows the Current Debate, even while tools run one-off.
await addSource({ ...newSource, debateId: currentDebate?.id ?? null });
setNewSource({ title: '', content: '', type: 'text', origin: '', folder: newSource.folder });
setIsAdding(false);
- toast({ title: t('libraryPage.sourceAdded'), description: t('libraryPage.sourceAddedDesc') });
};
const handleImportUrl = async () => {
@@ -245,7 +267,6 @@ export default function LibraryPage() {
domain: imported.domain,
excerpt: imported.excerpt,
});
- toast({ title: t('libraryPage.importReady'), description: t('libraryPage.importReadyDesc') });
} catch (error) {
toast({
title: t('libraryPage.importFailed'),
@@ -257,8 +278,10 @@ export default function LibraryPage() {
}
};
- const handleFileImport = async (file: File | undefined) => {
+ const handleFileImport = async (input: HTMLInputElement) => {
+ const file = input.files?.[0];
if (!file) return;
+ setIsImportingFile(true);
try {
const imported = await importSourceFile(file);
setImportPreview({
@@ -279,6 +302,10 @@ export default function LibraryPage() {
description: isSourceError ? error.message : t('libraryPage.fileReadFailed'),
variant: 'destructive',
});
+ } finally {
+ setIsImportingFile(false);
+ // Reset so picking the same file again fires a change.
+ input.value = '';
}
};
@@ -304,7 +331,6 @@ export default function LibraryPage() {
setImportPreview(null);
setImportUrl('');
setIsAdding(false);
- toast({ title: t('libraryPage.sourceAdded'), description: t('libraryPage.sourceAddedDesc') });
};
const handleClearLibrary = async () => {
@@ -381,6 +407,7 @@ export default function LibraryPage() {
useEffect(() => {
if (!selectedSource) return;
+ setDraftErrors({});
setSourceDraft({
title: selectedSource.title,
content: selectedSource.content,
@@ -401,27 +428,50 @@ export default function LibraryPage() {
setSelectedFolder(folder);
setNewFolderName('');
setNewFolderOpen(false);
- toast({
- title: t('libraryPage.folderCreated', 'Folder created'),
- description: t('libraryPage.folderCreatedDesc', 'New sources can now be saved there.'),
- });
};
+ // A source can be put back exactly, so deleting acts at once and offers Undo instead of asking first.
const handleRemoveSource = (source: Source) => {
+ if (selectedSourceId === source.id) setSelectedSourceId(null);
void Promise.resolve(removeSource(source.id)).then(() => {
- if (selectedSourceId === source.id) {
- setSelectedSourceId(null);
- }
- toast({ title: t('common.deleted', 'Deleted'), description: t('libraryPage.sourceDeleted', 'Source removed.') });
+ toast({
+ title: t('libraryPage.sourceDeletedTitle', { title: source.title, defaultValue: 'Deleted {{title}}' }),
+ action: {
+ label: t('common.undo', 'Undo'),
+ onClick: () => {
+ void addSource({
+ id: source.id,
+ createdAt: source.createdAt,
+ title: source.title,
+ content: source.content,
+ type: source.type,
+ origin: source.origin,
+ folder: source.folder,
+ isActive: source.isActive,
+ metadata: source.metadata,
+ debateId: source.debateId ?? null,
+ }).catch(() => toast({ title: t('libraryPage.restoreFailed', 'Could not restore the source'), variant: 'destructive' }));
+ },
+ },
+ });
+ }).catch(() => {
+ toast({ title: t('common.error', 'Error'), description: t('libraryPage.deleteFailed', 'Could not delete the source.'), variant: 'destructive' });
});
};
+ const renameFolderTo = async (from: string, nextName: string) => {
+ await renameSourceFolder(from, nextName);
+ setSelectedFolder(nextName);
+ toast({ title: t('libraryPage.folderRenamed', 'Folder renamed'), description: t('libraryPage.folderRenamedDesc', 'Sources in this folder were moved to the new name.') });
+ };
const handleRenameFolder = async () => {
if (selectedFolder === 'all' || selectedFolder === 'General') return;
const nextName = folderRenameValue.trim();
if (!nextName || nextName === selectedFolder) return;
- if (folders.includes(nextName) && !window.confirm(t('libraryPage.folderMergeConfirm', 'A folder with this name already exists. Merge sources into it?'))) return;
- await renameSourceFolder(selectedFolder, nextName);
- setSelectedFolder(nextName);
- toast({ title: t('libraryPage.folderRenamed', 'Folder renamed'), description: t('libraryPage.folderRenamedDesc', 'Sources in this folder were moved to the new name.') });
+ // Merging into an existing folder can't be split again, so it asks first.
+ if (folders.includes(nextName)) {
+ setMergeTarget(nextName);
+ return;
+ }
+ await renameFolderTo(selectedFolder, nextName);
};
const handleDeleteSelectedFolder = async () => {
if (selectedFolder === 'all' || selectedFolder === 'General') return;
@@ -432,17 +482,13 @@ export default function LibraryPage() {
const handleSetSelectedFolderActive = async (isActive: boolean) => {
if (selectedFolder === 'all') return;
await setFolderActive(selectedFolder, isActive);
- toast({
- title: isActive ? t('libraryPage.folderActivated', 'Folder activated') : t('libraryPage.folderDeactivated', 'Folder deactivated'),
- description: isActive ? t('libraryPage.folderActivatedDesc', 'All sources in this folder are active.') : t('libraryPage.folderDeactivatedDesc', 'Sources stay saved but will not ground AI outputs.'),
- });
};
const handleSaveSourceDraft = async () => {
if (!selectedSource) return;
- if (!sourceDraft.title.trim() || !sourceDraft.content.trim()) {
- toast({ title: t('common.error'), description: t('libraryPage.errorRequired'), variant: "destructive" });
- return;
- }
+ const errors = requiredErrors(sourceDraft.title, sourceDraft.content);
+ setDraftErrors(errors);
+ if (errors.title) return draftTitleRef.current?.focus();
+ if (errors.content) return draftContentRef.current?.focus();
await updateSource(selectedSource.id, {
title: sourceDraft.title,
@@ -473,13 +519,18 @@ export default function LibraryPage() {
-
{t('libraryPage.title')}
+
{t('libraryPage.title')}
{currentDebate ? (
-
- {t('debate.librarySources', 'Sources for')}{' '}
- {currentDebate.motion} {' '}
- openDebateDialog('switch')}>{t('debate.change', 'Change')}
-
+ <>
+
+ {t('debate.librarySources', 'Sources for')}{' '}
+ {currentDebate.motion} {' '}
+ openDebateDialog('switch')}>{t('debate.change', 'Change')}
+
+
+ {t('libraryPage.summary', { total: sourceLibrary.length, active: activeSourceCount, folders: folders.length, defaultValue: '{{total}} sources · {{active}} active · {{folders}} folders' })}
+
+ >
) : (
{t('debate.libraryNeedsDebate', 'Select or create a debate before adding or importing sources.')}
@@ -492,18 +543,6 @@ export default function LibraryPage() {
setIsAdding(!isAdding)}>
{isAdding ? t('libraryPage.cancel') : t('libraryPage.addSource')}
-
-
-
-
-
-
- {t('libraryPage.allFolders', 'All')}
- {folders.map((f) => (
- {getFolderName(f)}
- ))}
-
-
setSortBy(value as typeof sortBy)}>
-
+
@@ -560,7 +599,7 @@ export default function LibraryPage() {
-
+
{isClearing ? t('libraryPage.clearing') : t('libraryPage.clearAll')}
@@ -571,7 +610,7 @@ export default function LibraryPage() {
{t('common.cancel', 'Cancel')}
- void handleClearLibrary()}>
+ void handleClearLibrary()}>
{t('libraryPage.clearAll')}
@@ -580,21 +619,6 @@ export default function LibraryPage() {
-
-
-
{t('libraryPage.totalSources', 'Total sources')}
-
{sourceLibrary.length}
-
-
-
{t('libraryPage.activeSources', 'Active sources')}
-
{activeSourceCount}
-
-
-
{t('libraryPage.syncedFolders', 'Synced folders')}
-
{folders.length}
-
-
-
{isAdding && currentDebate && (
@@ -604,6 +628,21 @@ export default function LibraryPage() {
+ {/* One folder for whatever this form saves, imported or typed. */}
+
+
{t('libraryPage.folder', 'Folder')}
+
setNewSource({ ...newSource, folder: f })}>
+
+
+
+
+ {['General', ...folders.filter(f => f !== 'General')].map((f) => (
+ {getFolderName(f)}
+ ))}
+
+
+
{t('libraryPage.newFolderHint', 'Need a new folder? Use the "New folder" button up top.')}
+
{t('libraryPage.importUrlLabel')}
@@ -624,16 +663,18 @@ export default function LibraryPage() {
{t('libraryPage.importFileLabel')}
-
-
-
- {t('libraryPage.chooseFile')}
+ {/* The real input is invisible, so the label shows its focus ring. */}
+
+
+ {isImportingFile ? : }
+ {isImportingFile ? t('libraryPage.readingFile', 'Reading file…') : t('libraryPage.chooseFile')}
void handleFileImport(event.target.files?.[0])}
+ onChange={(event) => void handleFileImport(event.currentTarget)}
/>
@@ -670,19 +711,6 @@ export default function LibraryPage() {
onChange={(e) => setImportPreview({ ...importPreview, title: e.target.value })}
/>
-
- {t('libraryPage.folder', 'Folder')}
- setNewSource({ ...newSource, folder: f })}>
-
-
-
-
- {['General', ...folders.filter(f => f !== 'General')].map((f) => (
- {getFolderName(f)}
- ))}
-
-
-
{t('libraryPage.contentLabel')}
void handleSaveSourceDraft()}>
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
index 827b969f1..cf2931dab 100644
--- a/src/pages/LoginPage.tsx
+++ b/src/pages/LoginPage.tsx
@@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
-import { Sparkles } from 'lucide-react';
+import { ArrowLeft } from 'lucide-react';
import { useAuth } from '@/lib/supabase/AuthProvider';
import { useToast } from '@/hooks/use-toast';
import { consumeWorkspaceInvite } from '@/lib/workspace/invite-handoff';
+import { localizeAuthError } from '@/lib/auth-errors';
type AuthMode = 'email' | 'demo';
type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error';
@@ -32,6 +33,8 @@ export default function LoginPage() {
const [demoCode, setDemoCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const otpInputRef = useRef(null);
+ const emailInputRef = useRef(null);
+ const demoInputRef = useRef(null);
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
@@ -46,6 +49,13 @@ export default function LoginPage() {
if (authStatus === 'code-sent') otpInputRef.current?.focus();
}, [authStatus]);
+ // An error is shown under the field it is about, and focus moves there.
+ useEffect(() => {
+ if (authStatus !== 'error') return;
+ const field = authMode === 'demo' ? demoInputRef : emailSent ? otpInputRef : emailInputRef;
+ field.current?.focus();
+ }, [authStatus, authMode, emailSent]);
+
const handleGoogleSignIn = async () => {
setIsRedirecting(true);
try {
@@ -54,7 +64,7 @@ export default function LoginPage() {
setIsRedirecting(false);
toast({
title: t('login.failed', 'Could not start sign in'),
- description: error instanceof Error ? error.message : t('login.tryAgain', 'Please try again.'),
+ description: localizeAuthError(error, t, 'login.tryAgain', 'Please try again.'),
variant: 'destructive',
});
}
@@ -86,7 +96,7 @@ export default function LoginPage() {
setAuthStatus('code-sent');
} catch (error) {
setAuthStatus('error');
- setErrorMessage(error instanceof Error ? error.message : t('login.emailOtpError', 'Could not send code.'));
+ setErrorMessage(localizeAuthError(error, t, 'login.emailOtpError', 'Could not send code.'));
}
};
@@ -107,7 +117,7 @@ export default function LoginPage() {
setAuthStatus('success');
} catch (error) {
setAuthStatus('error');
- setErrorMessage(error instanceof Error ? error.message : t('login.verifyOtpError', 'Could not verify code.'));
+ setErrorMessage(localizeAuthError(error, t, 'login.verifyOtpError', 'Could not verify code.'));
}
};
@@ -120,7 +130,7 @@ export default function LoginPage() {
setAuthStatus('code-sent');
} catch (error) {
setAuthStatus('error');
- setErrorMessage(error instanceof Error ? error.message : t('login.emailOtpError', 'Could not send code.'));
+ setErrorMessage(localizeAuthError(error, t, 'login.emailOtpError', 'Could not send code.'));
}
};
@@ -140,18 +150,29 @@ export default function LoginPage() {
setAuthStatus('success');
} catch (error) {
setAuthStatus('error');
- setErrorMessage(error instanceof Error ? error.message : t('login.demoError', 'Could not sign in with demo code.'));
+ setErrorMessage(localizeAuthError(error, t, 'login.demoError', 'Could not sign in with demo code.'));
}
};
const isSubmitting = authStatus === 'submitting' || authStatus === 'verifying';
+ const hasError = authStatus === 'error' && Boolean(errorMessage);
+ const fieldError = (id: string) => hasError && (
+ {errorMessage}
+ );
+ const describedBy = (hintId: string, errorId: string) => hasError ? `${hintId} ${errorId}` : hintId;
return (
-
+
+
+
+
+ {t('login.backHome', 'Back to DebateLab')}
+
+
-
-
+
+
{/* The page heading, styled like CardTitle (which renders an h3). */}
{t('login.title')}
@@ -194,10 +215,11 @@ export default function LoginPage() {
maxLength={6}
value={otp}
onChange={(event) => setOtp(event.target.value)}
- aria-describedby="login-otp-hint"
+ aria-describedby={describedBy('login-otp-hint', 'login-otp-error')}
aria-invalid={authStatus === 'error'}
disabled={isSubmitting || authStatus === 'success'}
/>
+ {fieldError('login-otp-error')}
{t('login.codeSentDesc', 'Check your email for the six-digit code.')}
@@ -217,6 +239,7 @@ export default function LoginPage() {
{t('login.emailLabel', 'Email address')}
setEmail(event.target.value)}
- aria-describedby="login-email-hint"
+ aria-describedby={describedBy('login-email-hint', 'login-email-error')}
aria-invalid={authStatus === 'error'}
/>
+ {fieldError('login-email-error')}
{t('login.emailHint', 'We\'ll send a six-digit code.')}
@@ -243,6 +267,7 @@ export default function LoginPage() {
{t('login.demoCodeLabel', 'Demo code')}
setDemoCode(event.target.value)}
- aria-describedby="login-demo-code-hint"
+ aria-describedby={describedBy('login-demo-code-hint', 'login-demo-code-error')}
aria-invalid={authStatus === 'error'}
/>
+ {fieldError('login-demo-code-error')}
{t('login.demoCodeHint', 'Enter the code provided by your event host.')}
@@ -265,20 +291,15 @@ export default function LoginPage() {
{authStatus === 'code-sent' && (
-
+
{t('login.codeSent', 'Code sent')}
)}
{authStatus === 'success' && (
-
+
{t('login.signedIn', 'Signed in')}
)}
- {authStatus === 'error' && errorMessage && (
-
- {errorMessage}
-
- )}
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
index 89d46bd1e..8c5b1e8a8 100644
--- a/src/pages/NotFound.tsx
+++ b/src/pages/NotFound.tsx
@@ -1,18 +1,21 @@
+import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { PublicPageShell, useHomePath } from "@/components/layout/PublicPageShell";
const NotFound = () => {
const { t } = useTranslation();
+ const home = useHomePath();
return (
-
+
+ {t('notFound.title')}
+ {t('notFound.message')}
+ {/* An in-app link: no full reload through the startup shell, and signed-in users stay in the app. */}
+
+ {t('notFound.link')}
+
+
);
};
diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx
index 717e4337c..20fac1465 100644
--- a/src/pages/PrivacyPage.tsx
+++ b/src/pages/PrivacyPage.tsx
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
+import { PublicPageShell } from '@/components/layout/PublicPageShell';
export default function PrivacyPage() {
const { t } = useTranslation();
@@ -6,12 +7,11 @@ export default function PrivacyPage() {
const sections = Array.from({ length: 10 }, (_, i) => i + 1);
return (
-
-
+
{t('privacyPage.title')}
{t('privacyPage.lastUpdated')}: {t('privacyPage.date')}
-
+
{sections.map((n) => (
{n}. {t(`privacyPage.s${n}`)}
@@ -19,7 +19,6 @@ export default function PrivacyPage() {
))}
-
-
+
);
}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index e020fbb15..0cc2e101e 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -31,14 +31,16 @@ import { EnvironmentSwitcher } from '@/components/features/settings/EnvironmentS
import { formatEuropeanDate } from '@/lib/date-format';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
+import { PageHeader } from '@/components/layout/PageHeader';
import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages';
+// Tinted badges keep their label readable (white on amber or green was about 2:1).
const tierLabels: Record = {
- trial: { labelKey: 'settings.tierTrial', color: 'bg-blue-500' },
- pro: { labelKey: 'settings.tierPro', color: 'bg-amber-500' },
- legacy_free: { labelKey: 'settings.tierLegacyFree', color: 'bg-green-500' },
- expired: { labelKey: 'settings.tierExpired', color: 'bg-red-500' },
+ trial: { labelKey: 'settings.tierTrial', color: 'border-blue-500/30 bg-blue-500/15 text-blue-800 dark:text-blue-300' },
+ pro: { labelKey: 'settings.tierPro', color: 'border-amber-500/30 bg-amber-500/15 text-amber-800 dark:text-amber-300' },
+ legacy_free: { labelKey: 'settings.tierLegacyFree', color: 'border-success/30 bg-success/15 text-success' },
+ expired: { labelKey: 'settings.tierExpired', color: 'border-destructive/30 bg-destructive/10 text-destructive' },
};
export default function SettingsPage() {
@@ -57,6 +59,8 @@ export default function SettingsPage() {
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const [accountConfirmation, setAccountConfirmation] = useState('');
const [deletingAccount, setDeletingAccount] = useState(false);
+ const [deletingData, setDeletingData] = useState(false);
+ const [signingOut, setSigningOut] = useState(false);
const navigate = useNavigate();
// Must match deletionConfirmation in supabase/functions/account/index.ts.
const expectedAccountConfirmation = user?.email || 'DELETE';
@@ -92,6 +96,8 @@ export default function SettingsPage() {
return;
}
+ if (deletingData) return;
+ setDeletingData(true);
void clearAllData().then(() => {
clearLocalData();
setDeleteDialogOpen(false);
@@ -100,7 +106,7 @@ export default function SettingsPage() {
}).catch((error) => {
console.error('Failed to delete data:', error);
toast({ title: t('settings.deleteFailed'), description: t('settings.deleteFailedDesc'), variant: "destructive" });
- });
+ }).finally(() => setDeletingData(false));
};
const handleDownloadData = async () => {
@@ -141,10 +147,13 @@ export default function SettingsPage() {
};
const handleSignOut = () => {
+ if (signingOut) return;
+ setSigningOut(true);
void signOut()
.then(() => resetSparringLocalData())
.catch((error) => {
console.error('Failed to sign out:', error);
+ setSigningOut(false);
toast({ title: t('common.error', 'Error'), description: t('settings.signOutFailed', 'Could not sign out. Please try again.'), variant: 'destructive' });
});
};
@@ -179,14 +188,7 @@ export default function SettingsPage() {
return (
-
-
-
-
-
-
{t('settings.title')}
-
-
+
} title={t('settings.title')} />
{/* Subscription Status */}
@@ -203,7 +205,7 @@ export default function SettingsPage() {
<>
-
{t(tierInfo.labelKey)}
+
{t(tierInfo.labelKey)}
{subscription.tier === 'trial' && subscription.trialEndsAt && (
{t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)}
@@ -304,8 +306,8 @@ export default function SettingsPage() {
{t('settings.signedInAs')}
{user?.email || t('settings.unknownUser')}
-
- {t('settings.signOut')}
+
+ {signingOut ? t('settings.signingOut', 'Signing out…') : t('settings.signOut')}
@@ -321,9 +323,7 @@ export default function SettingsPage() {
- {user && adminRole.isPending && (
-
- )}
+ {/* Nothing renders while the admin check runs: almost nobody is an admin. */}
{user && adminRole.isError && (
{t('settings.adminAccessFailed', 'Could not check admin access.')}
@@ -383,7 +383,7 @@ export default function SettingsPage() {
onOpenChange={setPricingOpen}
onPromoRedeemed={() => void subscription.refresh()}
/>
-
+ { if (!deletingData) setDeleteDialogOpen(open); }}>
{t('settings.deleteDialogTitle')}
@@ -399,12 +399,13 @@ export default function SettingsPage() {
onChange={(event) => setDeleteEmail(event.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && deleteEmail === user?.email) handleDeleteAllData(); }}
placeholder={user?.email || ''}
+ disabled={deletingData}
/>
- setDeleteDialogOpen(false)}>{t('common.cancel', 'Cancel')}
-
- {t('settings.deleteAllData')}
+ setDeleteDialogOpen(false)} disabled={deletingData}>{t('common.cancel', 'Cancel')}
+
+ {deletingData ? t('settings.deletingData', 'Deleting…') : t('settings.deleteAllData')}
diff --git a/src/pages/SharedPage.tsx b/src/pages/SharedPage.tsx
index ca5f83f8b..955e5bdfa 100644
--- a/src/pages/SharedPage.tsx
+++ b/src/pages/SharedPage.tsx
@@ -1,13 +1,20 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
-import { useParams, useNavigate } from 'react-router-dom';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
-import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/lib/supabase/client';
-import { Loader2, Download, ExternalLink, Share2 } from 'lucide-react';
+import { Loader2, Share2 } from 'lucide-react';
import { formatEuropeanDate } from '@/lib/date-format';
+import { PublicPageShell, useHomePath } from '@/components/layout/PublicPageShell';
+import { GenerateOutputSchema } from '@/lib/ai/schemas/generate';
+import { AttackOutputSchema } from '@/lib/ai/schemas/attack';
+import { BrainstormOutputSchema } from '@/lib/ai/schemas/brainstorm';
+import { FactoidsOutputSchema } from '@/lib/ai/schemas/factoids';
+import { GenerateResults } from '@/components/features/generate/GenerateResults';
+import { AttackResults } from '@/components/features/attack/AttackResults';
+import { BrainstormResults } from '@/components/features/brainstorm/BrainstormResults';
+import { FactoidsResults } from '@/components/features/factoids/FactoidsResults';
interface SharedItem {
id: string;
@@ -19,15 +26,69 @@ interface SharedItem {
view_count: number;
}
+/**
+ * Shared data is public input, so it is checked against the tool's schema before the
+ * tool's own result view renders it. Anything else falls back to plain readable text.
+ */
+function renderSharedResult(item: SharedItem): ReactNode | null {
+ switch (item.item_type) {
+ case 'case':
+ case 'generate': {
+ const parsed = GenerateOutputSchema.safeParse(item.item_data);
+ return parsed.success ?
: null;
+ }
+ case 'attack': {
+ const parsed = AttackOutputSchema.safeParse(item.item_data);
+ return parsed.success ?
: null;
+ }
+ case 'brainstorm': {
+ const parsed = BrainstormOutputSchema.safeParse(item.item_data);
+ return parsed.success ?
: null;
+ }
+ case 'factoids': {
+ const parsed = FactoidsOutputSchema.safeParse(item.item_data);
+ return parsed.success ?
: null;
+ }
+ default:
+ return null;
+ }
+}
+
+/** Plain text for any shape we don't have a view for: labels and values, never raw JSON. */
+function PlainValue({ value }: { value: unknown }): ReactNode {
+ if (value === null || value === undefined || value === '') return null;
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return
{String(value)}
;
+ }
+ if (Array.isArray(value)) {
+ return (
+
+ {value.map((entry, index) => )}
+
+ );
+ }
+ if (typeof value === 'object') {
+ return (
+
+ {Object.entries(value as Record).map(([key, entry]) => (
+
+
{key.replace(/([a-z])([A-Z])/g, '$1 $2')}
+
+
+ ))}
+
+ );
+ }
+ return null;
+}
+
export default function SharedPage() {
const { t } = useTranslation();
const { code } = useParams<{ code: string }>();
- const navigate = useNavigate();
+ const home = useHomePath();
const [loading, setLoading] = useState(true);
const [item, setItem] = useState
(null);
const [error, setError] = useState(null);
- const { toast } = useToast();
-
useEffect(() => {
const loadSharedItem = async () => {
@@ -50,7 +111,7 @@ export default function SharedPage() {
}
setItem(data as SharedItem);
- } catch (err) {
+ } catch {
setError(t('sharedPage.loadFailed'));
} finally {
setLoading(false);
@@ -60,103 +121,49 @@ export default function SharedPage() {
void loadSharedItem();
}, [code, t]);
- const handleImport = async () => {
- if (!item) return;
-
- try {
- const { data: { user } } = await supabase.auth.getUser();
-
- if (!user) {
- toast({
- title: t('sharedPage.loginRequired'),
- description: t('sharedPage.loginRequiredDesc'),
- variant: 'default',
- });
- navigate('/login');
- return;
- }
-
- toast({
- title: t('sharedPage.importNotAvailable'),
- description: t('sharedPage.importNotAvailableDesc'),
- });
- } catch (err) {
- toast({
- title: t('common.error'),
- description: t('sharedPage.importFailed'),
- variant: 'destructive',
- });
- }
- };
-
if (loading) {
return (
-
-
-
+
+
+
);
}
if (error || !item) {
return (
-
-
-
-
-
- {t('sharedPage.title')}
-
- {error || t('sharedPage.notFound')}
-
-
- navigate('/app')} className="w-full">
- {t('sharedPage.goToApp')}
-
-
-
-
+
+
+ {t('sharedPage.title')}
+ {error || t('sharedPage.notFound')}
+
+ {t('sharedPage.goToApp')}
+
+
);
}
- return (
-
-
-
-
-
-
-
-
- {item.title || t('sharedPage.title')}
-
-
- {item.item_type}
- {t('sharedPage.sharedOn')} {formatEuropeanDate(item.created_at)}
- • {item.view_count} {t('sharedPage.views')}
-
-
-
-
- {t('sharedPage.importToAccount')}
-
-
-
-
-
-
- {JSON.stringify(item.item_data, null, 2)}
-
-
-
-
+ const result = renderSharedResult(item);
-
-
navigate('/app')} className="gap-2">
-
- {t('sharedPage.openApp')}
-
+ return (
+
+
+ {result ?? (
+
+ )}
+
+
+ {t('sharedPage.openApp')}
+
-
+
);
}
diff --git a/src/pages/SourcesPage.tsx b/src/pages/SourcesPage.tsx
index 32df6075f..a2f81fc4b 100644
--- a/src/pages/SourcesPage.tsx
+++ b/src/pages/SourcesPage.tsx
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
+import { PublicPageShell } from '@/components/layout/PublicPageShell';
export default function SourcesPage() {
const { t } = useTranslation();
@@ -126,11 +127,10 @@ export default function SourcesPage() {
];
return (
-
-
+
{t('sourcesPage.title')}
-
+
{t('sourcesPage.intro')}
@@ -158,7 +158,6 @@ export default function SourcesPage() {
-
-
+
);
}
diff --git a/src/pages/TermsPage.tsx b/src/pages/TermsPage.tsx
index d3227b6a3..462f0f2b6 100644
--- a/src/pages/TermsPage.tsx
+++ b/src/pages/TermsPage.tsx
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
+import { PublicPageShell } from '@/components/layout/PublicPageShell';
export default function TermsPage() {
const { t } = useTranslation();
@@ -6,12 +7,11 @@ export default function TermsPage() {
const sections = Array.from({ length: 10 }, (_, i) => i + 1);
return (
-
-
+
{t('termsPage.title')}
{t('termsPage.lastUpdated')}: {t('termsPage.date')}
-
+
{sections.map((n) => (
{n}. {t(`termsPage.s${n}`)}
@@ -19,7 +19,6 @@ export default function TermsPage() {
))}
-
-
+
);
}
diff --git a/src/test/debate/debate-gates.test.tsx b/src/test/debate/debate-gates.test.tsx
index 923172e5f..f15d25beb 100644
--- a/src/test/debate/debate-gates.test.tsx
+++ b/src/test/debate/debate-gates.test.tsx
@@ -30,9 +30,14 @@ describe('five-day Current Debate gate', () => {
expect(dialog).toHaveTextContent('This House would ban homework');
const buttons = Array.from(dialog.querySelectorAll('button')).map((button) => button.textContent);
expect(buttons).toEqual(['Switch debate', 'Start new debate', 'Continue']);
- // Escape does not dismiss it.
- fireEvent.keyDown(dialog, { key: 'Escape' });
- expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ });
+
+ it('treats Escape as Continue, so the gate never traps the user', async () => {
+ seedCurrentDebate({ lastActivityAt: Date.now() - 6 * DAY });
+ renderGate();
+ fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' });
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(Date.now() - useDebateStore.getState().debates[0].lastActivityAt).toBeLessThan(1000);
});
it('stays out of the way before five days and before the server state loads', () => {
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 4101b9325..edcd2fa97 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -59,7 +59,8 @@ export default {
}
},
transitionDuration: {
- '120': '120ms'
+ '120': '120ms',
+ '180': '180ms'
},
transitionTimingFunction: {
'out-strong': 'cubic-bezier(0.23, 1, 0.32, 1)',