UI Changes
This commit is contained in:
@@ -1,35 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { apiRequest } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input'; // Import Input component
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {APPROVE_REASONS, REJECT_REASONS} from '@/lib/reasons';
|
||||
|
||||
const VotingPage = () => {
|
||||
const [video, setVideo] = useState<any>(null);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null); // State for the blob URL
|
||||
const [reason, setReason] = useState('');
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [currentDecision, setCurrentDecision] = useState<'approve' | 'reject' | null>(null);
|
||||
const [selectedReasons, setSelectedReasons] = useState<string[]>([]);
|
||||
const [otherReason, setOtherReason] = useState('');
|
||||
const [videoTimestamp, setVideoTimestamp] = useState<number>(0);
|
||||
const [recommendedGameInput, setRecommendedGameInput] = useState<string>(''); // State for editable game name
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const auth = useAuth();
|
||||
|
||||
// This effect runs when the `video` metadata is fetched
|
||||
// Effect to fetch video metadata and blob URL
|
||||
useEffect(() => {
|
||||
// If there's no video metadata, do nothing.
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
let objectUrl: string; // To keep track of the URL for cleanup
|
||||
let objectUrl: string;
|
||||
|
||||
const fetchVideoBlob = async () => {
|
||||
try {
|
||||
// Fetch the video stream as a raw response, not JSON
|
||||
const response = await apiRequest(`/videos/${video.id}/stream`, {
|
||||
token: auth.token,
|
||||
wantsJson: false // We expect a blob, not JSON
|
||||
wantsJson: false
|
||||
});
|
||||
|
||||
const blob = await response.blob();
|
||||
@@ -43,7 +58,9 @@ const VotingPage = () => {
|
||||
|
||||
fetchVideoBlob();
|
||||
|
||||
// Cleanup function: revoke the object URL to free up memory
|
||||
// Initialize recommendedGameInput with the current video's game
|
||||
setRecommendedGameInput(video.game || '');
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
@@ -51,16 +68,34 @@ const VotingPage = () => {
|
||||
};
|
||||
}, [video, auth.token]);
|
||||
|
||||
// Effect to handle video time updates
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (videoElement) {
|
||||
const handleTimeUpdate = () => {
|
||||
setVideoTimestamp(videoElement.currentTime);
|
||||
};
|
||||
|
||||
videoElement.addEventListener('timeupdate', handleTimeUpdate);
|
||||
|
||||
return () => {
|
||||
videoElement.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
};
|
||||
}
|
||||
}, [videoUrl]);
|
||||
|
||||
const fetchNextVideo = async () => {
|
||||
// Reset all states for the new video
|
||||
setError('');
|
||||
setMessage('');
|
||||
setVideo(null);
|
||||
setVideoUrl(null);
|
||||
setReason('');
|
||||
setSelectedReasons([]);
|
||||
setOtherReason('');
|
||||
setCurrentDecision(null);
|
||||
setShowModal(false);
|
||||
setVideoTimestamp(0);
|
||||
setRecommendedGameInput(''); // Reset recommended game input
|
||||
try {
|
||||
// This just gets the video metadata (id, person, game)
|
||||
const data = await apiRequest('/videos/vote-next', { token: auth.token });
|
||||
setVideo(data);
|
||||
} catch (err: any) {
|
||||
@@ -68,74 +103,172 @@ const VotingPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitVote = async (decision: string) => {
|
||||
if (!video) return;
|
||||
const handleVoteButtonClick = (decision: 'approve' | 'reject') => {
|
||||
setCurrentDecision(decision);
|
||||
setSelectedReasons([]);
|
||||
setOtherReason('');
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleReasonChange = (reasonOption: string) => {
|
||||
setSelectedReasons(prev =>
|
||||
prev.includes(reasonOption)
|
||||
? prev.filter(r => r !== reasonOption)
|
||||
: [...prev, reasonOption]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmitVote = async () => {
|
||||
if (!video || !currentDecision) return;
|
||||
setError('');
|
||||
setMessage('');
|
||||
|
||||
const finalReasons = [...selectedReasons];
|
||||
if (otherReason.trim()) {
|
||||
finalReasons.push(otherReason.trim());
|
||||
}
|
||||
|
||||
// Prepare the body for the API request
|
||||
const body: { decision: string; reason: string[] | string; recommended_game?: string; timestamp: number } = {
|
||||
decision: currentDecision,
|
||||
reason: finalReasons, // Send reasons as an array
|
||||
timestamp: videoTimestamp
|
||||
};
|
||||
|
||||
// Add recommended_game only if it has been changed from the original
|
||||
if (recommendedGameInput.trim() !== (video.game || '').trim()) {
|
||||
body.recommended_game = recommendedGameInput.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const body = { decision, reason, recommended_game: '' };
|
||||
await apiRequest(`/videos/${video.id}/vote`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
token: auth.token
|
||||
});
|
||||
setMessage(`Vote '${decision}' submitted successfully!`);
|
||||
// Reset state to prepare for the next video
|
||||
setVideo(null);
|
||||
setVideoUrl(null);
|
||||
setReason('');
|
||||
setMessage(`Vote '${currentDecision}' submitted successfully!`);
|
||||
setShowModal(false);
|
||||
fetchNextVideo();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const reasonsToDisplay = currentDecision === 'approve' ? APPROVE_REASONS : REJECT_REASONS;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vote on a Video</CardTitle>
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<Card className="rounded-lg shadow-lg">
|
||||
<CardHeader className="bg-gray-50 p-4 rounded-t-lg">
|
||||
<CardTitle className="text-2xl font-bold text-gray-800">Vote on a Video</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<CardContent className="p-6 space-y-6">
|
||||
{!video && (
|
||||
<div className="text-center">
|
||||
<Button onClick={fetchNextVideo}>Get Next Video to Vote On</Button>
|
||||
<div className="text-center py-8">
|
||||
<Button
|
||||
onClick={fetchNextVideo}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg shadow-md transition duration-300 ease-in-out"
|
||||
>
|
||||
Get Next Video to Vote On
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-center text-red-500">{error}</p>}
|
||||
{message && <p className="text-center text-green-500">{message}</p>}
|
||||
{error && <p className="text-center text-red-500 font-medium">{error}</p>}
|
||||
{message && <p className="text-center text-green-500 font-medium">{message}</p>}
|
||||
|
||||
{/* The video player now uses the local blob URL */}
|
||||
{video && videoUrl && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden shadow-xl">
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={video.id}
|
||||
className="w-full rounded-lg bg-black"
|
||||
className="w-full h-auto max-h-[500px] object-contain"
|
||||
controls
|
||||
src={videoUrl} // Use the state variable for the src
|
||||
src={videoUrl}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p><strong>Person:</strong> {video.person}</p>
|
||||
<p><strong>Game:</strong> {video.game || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reason">Reason (Optional)</label>
|
||||
<Textarea id="reason" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
<div className="text-sm text-gray-700 bg-gray-100 p-4 rounded-lg shadow-inner">
|
||||
<p><strong className="font-semibold">Person:</strong> {video.person}</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<strong className="font-semibold">Game:</strong>
|
||||
<Input
|
||||
type="text"
|
||||
value={recommendedGameInput}
|
||||
onChange={(e) => setRecommendedGameInput(e.target.value)}
|
||||
placeholder="Enter recommended game"
|
||||
className="flex-1 border-none focus:ring-0 focus:outline-none bg-transparent p-0 h-auto text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p><strong className="font-semibold">Current Timestamp:</strong> {videoTimestamp.toFixed(2)} seconds</p>
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<Button className="w-full bg-green-600 hover:bg-green-700" onClick={() => submitVote('approve')}>Approve</Button>
|
||||
<Button className="w-full bg-red-600 hover:bg-red-700" onClick={() => submitVote('reject')}>Reject</Button>
|
||||
<Button
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg shadow-md transition duration-300 ease-in-out"
|
||||
onClick={() => handleVoteButtonClick('approve')}
|
||||
>
|
||||
Approve 👍
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg shadow-md transition duration-300 ease-in-out"
|
||||
onClick={() => handleVoteButtonClick('reject')}
|
||||
>
|
||||
Reject 👎
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Reasons Modal */}
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 bg-white rounded-lg shadow-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-gray-800">
|
||||
Select Reasons for {currentDecision === 'approve' ? 'Approval' : 'Rejection'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4 max-h-[400px] overflow-y-auto">
|
||||
{reasonsToDisplay.map((option) => (
|
||||
<div key={option} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={option}
|
||||
checked={selectedReasons.includes(option)}
|
||||
onCheckedChange={() => handleReasonChange(option)}
|
||||
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<Label htmlFor={option} className="text-gray-700 cursor-pointer">
|
||||
{option}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label htmlFor="other-reason" className="text-gray-700">Other Reason (Optional)</Label>
|
||||
<Textarea
|
||||
id="other-reason"
|
||||
value={otherReason}
|
||||
onChange={(e) => setOtherReason(e.target.value)}
|
||||
placeholder="Enter any other reasons here..."
|
||||
className="w-full border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmitVote}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-300 ease-in-out"
|
||||
>
|
||||
Submit Vote
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VotingPage;
|
||||
export default VotingPage;
|
||||
|
||||
21
lib/reasons.ts
Normal file
21
lib/reasons.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const APPROVE_REASONS = [
|
||||
'High quality content',
|
||||
'Engaging gameplay',
|
||||
'Good commentary',
|
||||
'Good production value',
|
||||
'Clear audio/video',
|
||||
'Funny',
|
||||
'Action-packed',
|
||||
'Great game choice',
|
||||
'Good for rewatchability',
|
||||
'Used in existing video'
|
||||
];
|
||||
|
||||
export const REJECT_REASONS = [
|
||||
'Low quality content',
|
||||
'Unengaging gameplay',
|
||||
'Uninspiring',
|
||||
'Lacks depth',
|
||||
'Too long/short',
|
||||
'Racist/whatever-ist',
|
||||
];
|
||||
Reference in New Issue
Block a user