Spaces:
Sleeping
Sleeping
| "use client"; | |
| import {useState, useCallback, ChangeEvent, useEffect, useRef} from 'react'; | |
| import {Button} from '@/components/ui/button'; | |
| import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; | |
| import {Input} from '@/components/ui/input'; | |
| import {Label} from '@/components/ui/label'; | |
| import {Slider} from '@/components/ui/slider'; | |
| import {useToast} from '@/hooks/use-toast'; | |
| import {Toaster} from '@/components/ui/toaster'; | |
| import {Icons} from '@/components/icons'; | |
| import {Separator} from '@/components/ui/separator'; | |
| import { | |
| Sidebar, | |
| SidebarContent, | |
| SidebarFooter, | |
| SidebarHeader, | |
| SidebarMenu, | |
| SidebarMenuItem, | |
| SidebarProvider, | |
| } from '@/components/ui/sidebar'; | |
| import {Progress} from '@/components/ui/progress'; | |
| import { | |
| exportToCsv, | |
| getAllRatings, | |
| saveRating, | |
| saveUserInfo, | |
| } from '@/services/rating-service'; | |
| import {UserInfo} from '@/ai/db/schema'; | |
| import {Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table'; | |
| import {fetchAudioFiles} from '@/services/audio-service'; | |
| import {fetchDriveAudioFiles} from '@/services/drive-service'; | |
| import Image from 'next/image'; | |
| // Define the type based on the return value of getAllRatings() | |
| type RatingEntry = { | |
| userId: string; | |
| userInfo?: { name: string; email: string; }; | |
| audioA: string; | |
| audioB: string; | |
| rating: { | |
| audioA: string; | |
| audioB: string; | |
| ratingA: number; | |
| ratingB: number; | |
| }; | |
| }; | |
| const PasswordPage = () => { | |
| const [password, setPassword] = useState(''); | |
| const [name, setName] = useState(''); | |
| const [email, setEmail] = useState(''); | |
| const [isAuthenticated, setIsAuthenticated] = useState(false); | |
| const [isDeveloper, setIsDeveloper] = useState(false); | |
| const [loginError, setLoginError] = useState<string | null>(null); | |
| const {toast} = useToast(); | |
| // Minimal config state for admin info only | |
| const [config, setConfig] = useState<{ | |
| adminName?: string; | |
| adminEmail?: string; | |
| }>({}); | |
| const passwordInputRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| // Focus on the password input when the component mounts | |
| passwordInputRef.current?.focus(); | |
| }, []); | |
| // Update handlePasswordSubmit to authenticate via API | |
| const handlePasswordSubmit = async () => { | |
| try { | |
| setLoginError(null); | |
| const response = await fetch('/api/config', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| password, | |
| name, | |
| email, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| setIsAuthenticated(true); | |
| setIsDeveloper(data.isDeveloper); | |
| if (data.isDeveloper) { | |
| // Store admin info if user is developer | |
| setConfig({ | |
| adminName: data.adminName, | |
| adminEmail: data.adminEmail, | |
| }); | |
| toast({ | |
| title: 'Developer login successful!', | |
| description: `Welcome, ${name}!`, | |
| }); | |
| } else { | |
| // Regular user login | |
| const userInfo: UserInfo = {name, email}; | |
| await saveUserInfo(email, userInfo); // Use email as userId | |
| toast({ | |
| title: 'Login successful!', | |
| description: `Welcome, ${name}!`, | |
| }); | |
| } | |
| } else { | |
| setIsAuthenticated(false); | |
| setIsDeveloper(false); | |
| setLoginError(data.error || 'Invalid credentials'); | |
| toast({ | |
| title: 'Login failed', | |
| description: data.error || 'Incorrect credentials.', | |
| }); | |
| } | |
| } catch (error: any) { | |
| console.error('Authentication error:', error); | |
| setLoginError('Authentication failed. Please try again.'); | |
| toast({ | |
| title: 'Login failed', | |
| description: 'Authentication failed. Please try again.', | |
| }); | |
| } | |
| }; | |
| const handleEnterKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { | |
| if (e.key === 'Enter') { | |
| handlePasswordSubmit(); | |
| } | |
| }; | |
| if (!isAuthenticated) { | |
| return ( | |
| <div className="flex items-center justify-center h-screen bg-secondary"> | |
| <Card className="w-96"> | |
| <CardHeader> | |
| <CardTitle className="flex items-center space-x-2"> | |
| <Image | |
| src="https://www.hi-paris.fr/wp-content/uploads/2020/09/logo-hi-paris-retina.png" | |
| alt="Hi! Paris Logo" | |
| width={50} | |
| height={50} | |
| className="rounded-full" | |
| /> | |
| <span>Enter Credentials</span> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="flex flex-col space-y-4"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="name">Name</Label> | |
| <Input | |
| type="text" | |
| placeholder="Name" | |
| value={name} | |
| onChange={e => setName(e.target.value)} | |
| /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="email">Email</Label> | |
| <Input | |
| type="email" | |
| placeholder="Email" | |
| value={email} | |
| onChange={e => setEmail(e.target.value)} | |
| /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="password">Password</Label> | |
| <Input | |
| type="password" | |
| placeholder="Password" | |
| value={password} | |
| onChange={e => setPassword(e.target.value)} | |
| onKeyDown={handleEnterKeyPress} | |
| ref={passwordInputRef} | |
| /> | |
| </div> | |
| {loginError && <p className="text-red-500">{loginError}</p>} | |
| <Button onClick={handlePasswordSubmit}>Submit</Button> | |
| {/* Demo Credentials */} | |
| <div className="mt-6 p-4 bg-gray-50 rounded-lg border"> | |
| <h3 className="text-sm font-semibold text-gray-700 mb-3">Demo Credentials</h3> | |
| <div className="space-y-3"> | |
| <div className="text-xs"> | |
| <div className="flex justify-between items-center mb-1"> | |
| <span className="font-medium text-gray-600">Regular User:</span> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="h-6 text-xs px-2" | |
| onClick={() => { | |
| setName('Test User'); | |
| setEmail('user@example.com'); | |
| setPassword('demo'); | |
| }} | |
| > | |
| Fill Form | |
| </Button> | |
| </div> | |
| <div className="font-mono text-gray-800 space-y-1"> | |
| <div>Name: <span className="bg-white px-1 rounded">Any name</span></div> | |
| <div>Email: <span className="bg-white px-1 rounded">Any email</span></div> | |
| <div>Password: <span className="bg-white px-1 rounded">demo</span></div> | |
| </div> | |
| </div> | |
| <div className="text-xs"> | |
| <div className="flex justify-between items-center mb-1"> | |
| <span className="font-medium text-gray-600">Admin User:</span> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="h-6 text-xs px-2" | |
| onClick={() => { | |
| setName('admin'); | |
| setEmail('admin@tts.com'); | |
| setPassword('admin'); | |
| }} | |
| > | |
| Fill Form | |
| </Button> | |
| </div> | |
| <div className="font-mono text-gray-800 space-y-1"> | |
| <div>Name: <span className="bg-white px-1 rounded">admin</span></div> | |
| <div>Email: <span className="bg-white px-1 rounded">admin@tts.com</span></div> | |
| <div>Password: <span className="bg-white px-1 rounded">admin</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="mt-3 text-xs text-gray-500"> | |
| 💡 Admin users can export results and see additional analytics | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| ); | |
| } | |
| return <AudioRaterApp | |
| isDeveloper={isDeveloper} | |
| userEmail={email} | |
| userName={name} | |
| config={config} // Pass the config as a prop | |
| />; | |
| }; | |
| const AudioRaterApp = ({ | |
| isDeveloper, | |
| userEmail, | |
| userName, | |
| config, // Add config to props | |
| }: { | |
| isDeveloper: boolean; | |
| userEmail: string; | |
| userName: string; | |
| config: { // Define the config type | |
| adminName?: string; | |
| adminEmail?: string; | |
| }; | |
| }) => { | |
| const [audioPairs, setAudioPairs] = useState<[string, string, string, boolean][]>([]); | |
| const [currentPairIndex, setCurrentPairIndex] = useState(0); | |
| const [ratings, setRatings] = useState<{ a: number; b: number }[]>([]); | |
| const {toast} = useToast(); | |
| const [ratedPairs, setRatedPairs] = useState<number[]>([]); | |
| const [tempRatings, setTempRatings] = useState<{ a: number; b: number }>({ | |
| a: 0, | |
| b: 0, | |
| }); // Temporary ratings before submission | |
| const [ratingSubmitted, setRatingSubmitted] = useState(false); | |
| const [allRatings, setAllRatings] = useState<RatingEntry[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [audioLoading, setAudioLoading] = useState(true); | |
| const [audioError, setAudioError] = useState<string | null>(null); | |
| const [testCompleted, setTestCompleted] = useState(false); // State to track test completion | |
| useEffect(() => { | |
| const loadAudioFiles = async () => { | |
| setAudioLoading(true); | |
| setAudioError(null); // Clear any previous errors | |
| try { | |
| // Only use local files - remove Google Drive fetch | |
| const localFiles = await fetchAudioFiles(); | |
| if (localFiles && localFiles.length > 0) { | |
| setAudioPairs(localFiles); | |
| } else { | |
| setAudioError('No audio files found.'); | |
| toast({ | |
| title: 'No audio files found', | |
| description: 'Please check the audio files.' | |
| }); | |
| } | |
| } catch (error) { | |
| console.error('Error loading audio files:', error); | |
| setAudioError('Failed to load audio files.'); | |
| toast({ | |
| title: 'Error loading audio files', | |
| description: 'Failed to load audio files. Please try again.' | |
| }); | |
| } finally { | |
| setAudioLoading(false); | |
| } | |
| }; | |
| loadAudioFiles(); | |
| }, [toast]); | |
| useEffect(() => { | |
| loadInitialRatings(); | |
| }, [userEmail, audioPairs]); | |
| const loadInitialRatings = async () => { | |
| setLoading(true); | |
| try { | |
| const loadedRatings = await getAllRatings(); | |
| setAllRatings(loadedRatings); | |
| // Initialize ratings state with existing ratings for the current user | |
| const initialRatings = audioPairs.map(([audioA, audioB, audioName]) => { | |
| const existingRating = loadedRatings.find( | |
| rating => | |
| rating.userId === userEmail && | |
| rating.audioA === audioA && | |
| rating.audioB === audioB | |
| )?.rating; | |
| return existingRating ? {a: existingRating.ratingA, b: existingRating.ratingB} : {a: 0, b: 0}; | |
| }); | |
| setRatings(initialRatings); | |
| setRatedPairs( | |
| initialRatings.reduce((acc: number[], rating, index) => { | |
| if (rating.a > 0 && rating.b > 0) { | |
| acc.push(index); | |
| } | |
| return acc; | |
| }, []) | |
| ); | |
| // Set initial temporary ratings if there are existing ratings | |
| if (initialRatings[currentPairIndex]) { | |
| setTempRatings({ | |
| a: initialRatings[currentPairIndex].a || 0, | |
| b: initialRatings[currentPairIndex].b || 0, | |
| }); | |
| } | |
| } catch (error) { | |
| console.error('Error loading ratings:', error); | |
| toast({ | |
| title: 'Error loading ratings', | |
| description: 'Failed to load existing ratings. Please try again.', | |
| }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleRatingChange = (version: 'a' | 'b', rating: number) => { | |
| setTempRatings({...tempRatings, [version]: rating}); | |
| }; | |
| const handleSubmitRating = async () => { | |
| try { | |
| const {a, b} = tempRatings; | |
| if (a < 1 || a > 5 || b < 1 || b > 5) { | |
| toast({ | |
| title: 'Invalid rating', | |
| description: 'Please rate both versions between 1 and 5.', | |
| }); | |
| return; | |
| } | |
| const [audioA, audioB, audioName, swapped] = audioPairs[currentPairIndex]; | |
| // When saving to database, ensure the original file order is maintained | |
| await saveRating( | |
| userEmail, | |
| swapped ? audioB : audioA, // If swapped, audioB is the improved version | |
| swapped ? audioA : audioB, // If swapped, audioA is the raw version | |
| { | |
| ratingA: swapped ? tempRatings.b : tempRatings.a, // Map rating to correct audio | |
| ratingB: swapped ? tempRatings.a : tempRatings.b // Map rating to correct audio | |
| } | |
| ); | |
| const newRatings = [...ratings]; | |
| newRatings[currentPairIndex] = {a: tempRatings.a, b: tempRatings.b}; | |
| setRatings(newRatings); | |
| if (!ratedPairs.includes(currentPairIndex)) { | |
| setRatedPairs([...ratedPairs, currentPairIndex]); | |
| } | |
| toast({ | |
| title: 'Rating saved!', | |
| description: 'Your rating has been successfully saved.', | |
| }); | |
| setRatingSubmitted(true); | |
| // Move to the next pair automatically | |
| const nextIndex = (currentPairIndex + 1) % audioPairs.length; | |
| handlePairSelect(nextIndex); | |
| } catch (error: any) { | |
| console.error('Error submitting rating:', error.message); | |
| toast({ | |
| title: 'Error saving rating', | |
| description: 'Failed to save your rating. Please try again.', | |
| }); | |
| } | |
| }; | |
| const handlePairSelect = (index: number) => { | |
| setCurrentPairIndex(index); | |
| setRatingSubmitted(false); | |
| setTempRatings({ | |
| a: ratings[index]?.a || 0, | |
| b: ratings[index]?.b || 0, | |
| }); | |
| }; | |
| const ratingPercentage = | |
| audioPairs.length > 0 ? (ratedPairs.length / audioPairs.length) * 100 : 0; | |
| const currentPair = audioPairs[currentPairIndex]; | |
| const handleExportCsv = async () => { | |
| try { | |
| const csvData = await exportToCsv(); | |
| if (!csvData) { | |
| toast({title: 'No data to export', description: 'No ratings found.'}); | |
| return; | |
| } | |
| const blob = new Blob([csvData], {type: 'text/csv'}); | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.setAttribute('href', url); | |
| a.setAttribute('download', 'audio_ratings.csv'); | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| window.URL.revokeObjectURL(url); | |
| toast({title: 'CSV Exported', description: 'Data exported successfully.'}); | |
| } catch (error: any) { | |
| console.error('CSV Export Error:', error); | |
| toast({title: 'Export Failed', description: 'Error exporting data.'}); | |
| } | |
| }; | |
| const handleFinishTest = async () => { | |
| if (ratedPairs.length === audioPairs.length) { | |
| setTestCompleted(true); | |
| // Send export email to admin | |
| try { | |
| const adminEmail = config.adminEmail || 'tim.horstmann@ip-paris.fr'; | |
| const response = await fetch('/api/send-export', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| adminEmail: adminEmail, | |
| }), | |
| }); | |
| if (response.ok) { | |
| console.log('Export email sent successfully'); | |
| } else { | |
| console.error('Failed to send export email'); | |
| } | |
| } catch (error) { | |
| console.error('Error sending export email:', error); | |
| } | |
| } else { | |
| toast({ | |
| title: 'Cannot finish A/B test', | |
| description: 'Please rate all audio pairs before finishing.', | |
| }); | |
| } | |
| }; | |
| if (isDeveloper) { | |
| return ( | |
| <div className="container mx-auto p-4"> | |
| <h1 className="text-2xl font-bold mb-4">Developer View - All Ratings</h1> | |
| <Button onClick={handleExportCsv} className="mb-4"> | |
| Export to CSV | |
| </Button> | |
| {loading ? ( | |
| <div>Loading...</div> | |
| ) : ( | |
| <Table> | |
| <TableCaption>A list of all audio ratings in the database.</TableCaption> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead>User ID</TableHead> | |
| <TableHead>User Name</TableHead> | |
| <TableHead>User Email</TableHead> | |
| <TableHead>Audio A</TableHead> | |
| <TableHead>Audio B</TableHead> | |
| <TableHead>Rating A</TableHead> | |
| <TableHead>Rating B</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {allRatings.map((rating, index) => ( | |
| <TableRow key={index}> | |
| <TableCell>{rating.userId}</TableCell> | |
| <TableCell>{rating.userInfo?.name}</TableCell> | |
| <TableCell>{rating.userInfo?.email}</TableCell> | |
| <TableCell>{rating.audioA}</TableCell> | |
| <TableCell>{rating.audioB}</TableCell> | |
| <TableCell>{rating.rating.ratingA}</TableCell> | |
| <TableCell>{rating.rating.ratingB}</TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| )} | |
| <Toaster /> | |
| </div> | |
| ); | |
| } | |
| if (testCompleted) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center min-h-screen p-4 bg-secondary"> | |
| <Card className="w-full max-w-md"> | |
| <CardHeader> | |
| <CardTitle>Thank You!</CardTitle> | |
| </CardHeader> | |
| <CardContent className="text-center"> | |
| <p className="mb-4">Thank you for your participation in this A/B testing!</p> | |
| <div className="flex justify-center space-x-4"> | |
| <Button onClick={() => setTestCompleted(false)}> | |
| Review Ratings | |
| </Button> | |
| <Button variant="outline" onClick={() => window.location.href = 'https://www.hi-paris.fr/'}> | |
| Leave Website | |
| </Button> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <SidebarProvider> | |
| <Sidebar> | |
| <SidebarHeader> | |
| <CardTitle>Audio Pairs</CardTitle> | |
| </SidebarHeader> | |
| <SidebarContent> | |
| <SidebarMenu> | |
| {audioPairs.map((pair, index) => ( | |
| <SidebarMenuItem key={index}> | |
| <Button | |
| variant={index === currentPairIndex ? "default" : "ghost"} | |
| onClick={() => handlePairSelect(index)} | |
| className={`w-full justify-start ${ | |
| index === currentPairIndex | |
| ? 'bg-accent text-accent-foreground border-l-4 border-primary rounded-l-none' | |
| : 'hover:bg-accent/50' | |
| } transition-all duration-200`} | |
| > | |
| <div className="flex items-center w-full"> | |
| <span className="mr-2 text-sm font-semibold">{index + 1}.</span> | |
| <span className="truncate flex-1">{pair[2]}</span> | |
| {ratedPairs.includes(index) && ( | |
| <Icons.check className="ml-auto h-4 w-4 text-primary" /> | |
| )} | |
| </div> | |
| </Button> | |
| </SidebarMenuItem> | |
| ))} | |
| </SidebarMenu> | |
| </SidebarContent> | |
| <SidebarFooter></SidebarFooter> | |
| </Sidebar> | |
| <div className="md:pl-[16rem] flex flex-col items-center min-h-screen p-6 bg-gradient-to-b from-background to-secondary"> | |
| <Toaster /> | |
| {/* Header with progress */} | |
| <div className="w-full max-w-6xl mb-8"> | |
| <div className="flex justify-between items-center mb-4"> | |
| <h1 className="text-3xl font-bold">Audio A/B Test</h1> | |
| <Image | |
| src="https://www.hi-paris.fr/wp-content/uploads/2020/09/logo-hi-paris-retina.png" | |
| alt="Hi! Paris Logo" | |
| width={70} | |
| height={70} | |
| className="rounded-full shadow-md" | |
| /> | |
| </div> | |
| <Card className="p-4 mb-6"> | |
| <div className="mb-2"> | |
| <div className="flex justify-between items-center mb-1"> | |
| <span className="text-sm font-medium">Your progress</span> | |
| <span className="text-sm font-medium">{ratedPairs.length}/{audioPairs.length} pairs rated</span> | |
| </div> | |
| <Progress value={ratingPercentage} className="h-3" /> | |
| </div> | |
| {currentPair && ( | |
| <p className="text-sm text-muted-foreground"> | |
| Currently rating: <span className="font-semibold">{currentPair[2]}</span> | |
| </p> | |
| )} | |
| </Card> | |
| </div> | |
| {audioLoading ? ( | |
| <Card className="w-full max-w-6xl p-12 flex justify-center"> | |
| <div className="flex flex-col items-center"> | |
| <Icons.loader className="h-12 w-12 animate-spin text-primary mb-4" /> | |
| <p>Loading audio files...</p> | |
| </div> | |
| </Card> | |
| ) : audioError ? ( | |
| <Card className="w-full max-w-6xl p-12 bg-destructive/10"> | |
| <div className="flex flex-col items-center text-destructive"> | |
| <Icons.alertCircle className="h-12 w-12 mb-4" /> | |
| <p className="font-semibold">Error: {audioError}</p> | |
| </div> | |
| </Card> | |
| ) : currentPair ? ( | |
| <Card className="w-full max-w-6xl shadow-lg border-t-4 border-primary"> | |
| <CardHeader className="pb-2"> | |
| <div className="flex justify-between items-center"> | |
| <CardTitle className="text-2xl"> | |
| Audio Pair {currentPairIndex + 1} of {audioPairs.length} | |
| </CardTitle> | |
| <span className="text-sm bg-secondary px-3 py-1 rounded-full font-medium"> | |
| {currentPair[2]} | |
| </span> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="pt-6 pb-8 space-y-8"> | |
| {/* Enhanced Audio Players - Stacked for more space */} | |
| <div className="space-y-8"> | |
| <EnhancedAudioPlayer | |
| src={`/${currentPair[0]}`} | |
| title="Version A" | |
| rating={tempRatings.a} | |
| onRatingChange={(rating) => handleRatingChange('a', rating)} | |
| /> | |
| <Separator className="my-8" /> | |
| <EnhancedAudioPlayer | |
| src={`/${currentPair[1]}`} | |
| title="Version B" | |
| rating={tempRatings.b} | |
| onRatingChange={(rating) => handleRatingChange('b', rating)} | |
| /> | |
| </div> | |
| <div className="pt-4 flex justify-between items-center"> | |
| <div className="text-sm text-muted-foreground"> | |
| {ratingSubmitted ? ( | |
| <div className="flex items-center text-primary"> | |
| <Icons.check className="mr-2 h-4 w-4" /> | |
| Rating submitted | |
| </div> | |
| ) : ( | |
| "Please rate both versions to continue" | |
| )} | |
| </div> | |
| <div className="space-x-4"> | |
| {currentPairIndex > 0 && ( | |
| <Button | |
| variant="outline" | |
| onClick={() => handlePairSelect(currentPairIndex - 1)} | |
| className="gap-2" | |
| > | |
| <Icons.chevronLeft className="h-4 w-4" /> | |
| Previous | |
| </Button> | |
| )} | |
| <Button | |
| onClick={handleSubmitRating} | |
| disabled={ratingSubmitted || tempRatings.a < 1 || tempRatings.b < 1} | |
| className="gap-2" | |
| > | |
| {ratingSubmitted ? 'Submitted' : 'Submit Rating'} | |
| {!ratingSubmitted && <Icons.arrowRight className="h-4 w-4" />} | |
| </Button> | |
| {ratingSubmitted && ( | |
| <Button | |
| variant="secondary" | |
| onClick={() => handlePairSelect((currentPairIndex + 1) % audioPairs.length)} | |
| className="gap-2" | |
| > | |
| Next Pair | |
| <Icons.chevronRight className="h-4 w-4" /> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| <Card className="w-full max-w-6xl p-12"> | |
| <div className="text-center"> | |
| <Icons.audioLines className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> | |
| <p>No audio pairs available.</p> | |
| </div> | |
| </Card> | |
| )} | |
| {/* Finish test button - repositioned */} | |
| <Card className="w-full max-w-6xl mt-6 bg-background/80"> | |
| <CardContent className="flex justify-between items-center p-6"> | |
| <div className="text-sm max-w-md"> | |
| <p className="font-medium">Ready to complete the test?</p> | |
| <p className="text-muted-foreground">After finishing, your ratings will be submitted for analysis.</p> | |
| </div> | |
| <Button | |
| onClick={handleFinishTest} | |
| disabled={ratedPairs.length !== audioPairs.length} | |
| size="lg" | |
| className={`${ratedPairs.length === audioPairs.length ? 'bg-primary hover:bg-primary/90' : ''}`} | |
| > | |
| {ratedPairs.length === audioPairs.length ? 'Finish A/B Test' : `Rate ${audioPairs.length - ratedPairs.length} more pairs to finish`} | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </SidebarProvider> | |
| ); | |
| }; | |
| const EnhancedAudioPlayer = ({ | |
| src, | |
| title, | |
| rating, | |
| onRatingChange | |
| }: { | |
| src: string; | |
| title: string; | |
| rating: number; | |
| onRatingChange: (rating: number) => void; | |
| }) => { | |
| // For Google Drive URLs, use the full URL directly, for local files normalize the path | |
| const audioSrc = src.startsWith('https://') ? src : src.replace(/\\/g, '/'); | |
| return ( | |
| <div className="space-y-6"> | |
| <div className="bg-muted/30 p-4 rounded-lg border border-muted"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h3 className="text-lg font-semibold flex items-center gap-2"> | |
| <span className="bg-primary text-primary-foreground h-6 w-6 rounded-full flex items-center justify-center text-sm"> | |
| {title === "Version A" ? "A" : "B"} | |
| </span> | |
| {title} | |
| </h3> | |
| </div> | |
| <audio | |
| controls | |
| src={audioSrc} | |
| className="w-full h-12 rounded-md" | |
| > | |
| Your browser does not support the audio element. | |
| </audio> | |
| </div> | |
| <div className="space-y-3"> | |
| <div className="flex justify-between items-center"> | |
| <Label className="text-sm font-medium">Rate this audio:</Label> | |
| <span className="bg-primary/10 text-primary text-sm font-semibold px-2 py-1 rounded"> | |
| Score: {rating} | |
| </span> | |
| </div> | |
| <div className="flex items-center space-x-2"> | |
| <span className="text-sm font-medium text-muted-foreground">Poor</span> | |
| <Slider | |
| value={[rating]} | |
| min={1} | |
| max={5} | |
| step={1} | |
| onValueChange={(value) => onRatingChange(value[0])} | |
| className="flex-1" | |
| /> | |
| <span className="text-sm font-medium text-muted-foreground">Excellent</span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const AudioPlayer = ({src, title}: { src: string; title: string }) => { | |
| // For Google Drive URLs, use the full URL directly, for local files normalize the path | |
| const audioSrc = src.startsWith('https://') ? src : src.replace(/\\/g, '/'); | |
| return ( | |
| <div className="space-y-2"> | |
| <Label>{title}</Label> | |
| <audio controls src={audioSrc} className="w-full"> | |
| Your browser does not support the audio element. | |
| </audio> | |
| </div> | |
| ); | |
| }; | |
| const RatingSelector = ({ | |
| version, | |
| onChange, | |
| currentRating, | |
| }: { | |
| version: 'a' | 'b'; | |
| onChange: (version: 'a' | 'b', rating: number) => void; | |
| currentRating: number; | |
| }) => { | |
| return ( | |
| <div className="flex items-center space-x-4"> | |
| <Label className="w-24">Rating (1-5):</Label> | |
| <Slider | |
| defaultValue={[currentRating]} | |
| max={5} | |
| min={1} | |
| step={1} | |
| onValueChange={value => onChange(version, value[0])} | |
| className="max-w-xs" | |
| /> | |
| <span>{currentRating}</span> | |
| </div> | |
| ); | |
| }; | |
| export default PasswordPage; | |