| import { useCallback, useEffect, useState } from "react"; |
| import { |
| getSuggestedVideos, |
| listPredictions, |
| predict, |
| predictVideo, |
| } from "../api/client"; |
| import { CommentRow } from "../components/CommentRow"; |
| import { SuggestedRail } from "../components/SuggestedRail"; |
| import { useApp } from "../context/AppContext"; |
| import { useDebouncedPredict } from "../hooks/useDebouncedPredict"; |
| import { useI18n } from "../i18n/I18nContext"; |
| import type { |
| CommentItem, |
| PredictionRecord, |
| SuggestedVideo, |
| } from "../types/api"; |
| import { |
| formatPct, |
| newId, |
| randomTeamMember, |
| randomUsername, |
| relativeTime, |
| toxicityColor, |
| truncate, |
| } from "../utils/toxicity"; |
|
|
| const DEFAULT_EMBED_VIDEO_ID = "A1uxPRUgimk"; |
|
|
| function isPlaceholderTitle(title: string, id: string): boolean { |
| return title === `Video ${id}`; |
| } |
|
|
| export function WatchPage() { |
| const { t } = useI18n(); |
| const { threshold, addHubEntry } = useApp(); |
| const [draft, setDraft] = useState(""); |
| const [sessionComments, setSessionComments] = useState<CommentItem[]>([]); |
| const [suggested, setSuggested] = useState<SuggestedVideo[]>([]); |
| const [maxComments, setMaxComments] = useState(15); |
| const [activeVideo, setActiveVideo] = useState<SuggestedVideo | null>(null); |
| const [youtubeComments, setYoutubeComments] = useState<CommentItem[]>([]); |
| const [loadingVideoId, setLoadingVideoId] = useState<string | null>(null); |
| const [fetchError, setFetchError] = useState<string | null>(null); |
| const [demoBanner, setDemoBanner] = useState(false); |
| const [dismissDemoBanner, setDismissDemoBanner] = useState(false); |
| const [posting, setPosting] = useState(false); |
| const [recentActivity, setRecentActivity] = useState<PredictionRecord[]>([]); |
| const [recentLoading, setRecentLoading] = useState(false); |
|
|
| const { result, loading, error } = useDebouncedPredict(draft, threshold); |
|
|
| const refreshRecent = useCallback(async (videoId?: string) => { |
| if (!videoId) { |
| setRecentActivity([]); |
| return; |
| } |
| setRecentLoading(true); |
| try { |
| const res = await listPredictions(videoId, 200, "user_comment"); |
| setRecentActivity(Array.isArray(res?.predictions) ? res.predictions : []); |
| } catch { |
| |
| setRecentActivity([]); |
| } finally { |
| setRecentLoading(false); |
| } |
| }, []); |
|
|
| useEffect(() => { |
| void refreshRecent(activeVideo?.id); |
| }, [activeVideo?.id, refreshRecent]); |
|
|
| useEffect(() => { |
| getSuggestedVideos() |
| .then((r) => { |
| setSuggested(r.videos); |
| setMaxComments(r.max_comments); |
| |
| if (r.videos.length > 0) { |
| void loadVideo(r.videos[0]); |
| } |
| }) |
| .catch(() => setFetchError(t.watch.couldNotLoadVideos)); |
| |
| }, []); |
|
|
| const handlePost = useCallback(async () => { |
| const text = draft.trim(); |
| if (!text || posting) return; |
| setPosting(true); |
| try { |
| const author = randomTeamMember(); |
| const analysis = await predict(text, threshold, { |
| videoId: activeVideo?.id, |
| author, |
| persist: true, |
| }); |
| const item: CommentItem = { |
| id: newId(), |
| user: author, |
| text, |
| time: t.watch.justNow, |
| is_toxic: analysis.is_toxic, |
| probability: analysis.probability, |
| labels: analysis.labels, |
| source: "manual", |
| }; |
| setSessionComments((prev) => [...prev, item]); |
| addHubEntry({ |
| user: `@${author}`, |
| snippet: text.slice(0, 45), |
| score: analysis.probability, |
| action: analysis.is_toxic |
| ? `${t.watch.posted} (${t.badges.toxic.toLowerCase()})` |
| : t.badges.safe, |
| }); |
| setDraft(""); |
| void refreshRecent(activeVideo?.id); |
| } finally { |
| setPosting(false); |
| } |
| }, [draft, posting, threshold, addHubEntry, refreshRecent, activeVideo?.id, t]); |
|
|
| const loadVideo = async (video: SuggestedVideo) => { |
| setActiveVideo(video); |
| setYoutubeComments([]); |
| setSessionComments([]); |
| setFetchError(null); |
| setDismissDemoBanner(false); |
| setLoadingVideoId(video.id); |
| try { |
| const res = await predictVideo(video.watch_url, maxComments, threshold); |
| setDemoBanner(res.source === "demo"); |
| setYoutubeComments( |
| res.results.map((r, i) => ({ |
| id: `yt-${video.id}-${i}`, |
| user: randomUsername(`yt-${video.id}-${i}`), |
| text: r.text, |
| time: t.watch.fromYoutube, |
| is_toxic: r.is_toxic, |
| probability: r.probability, |
| labels: r.labels, |
| source: "youtube" as const, |
| })) |
| ); |
| } catch (e) { |
| setFetchError(e instanceof Error ? e.message : t.watch.failedToLoadComments); |
| setYoutubeComments([]); |
| setDemoBanner(false); |
| } finally { |
| setLoadingVideoId(null); |
| } |
| }; |
|
|
| const toxicManual = sessionComments.filter((c) => c.is_toxic).length; |
| const toxicYt = youtubeComments.filter((c) => c.is_toxic).length; |
| const totalComments = sessionComments.length + youtubeComments.length; |
|
|
| const channelInitial = activeVideo?.channel_title?.charAt(0).toUpperCase() ?? "Y"; |
|
|
| return ( |
| <div className="watch-page"> |
| <div className="watch-grid"> |
| <section className="primary-column"> |
| <div className="staged-player"> |
| {activeVideo && !activeVideo.embeddable ? ( |
| <a |
| className="player-fallback" |
| href={activeVideo.watch_url} |
| target="_blank" |
| rel="noopener noreferrer" |
| > |
| <img |
| src={activeVideo.thumbnail_url} |
| alt="" |
| className="player-fallback-thumb" |
| /> |
| <span className="player-fallback-cta">{t.watch.watchOnYoutube}</span> |
| </a> |
| ) : ( |
| <iframe |
| className="player-iframe" |
| src={`https://www.youtube.com/embed/${ |
| activeVideo?.id ?? DEFAULT_EMBED_VIDEO_ID |
| }?rel=0`} |
| title={activeVideo?.title ?? "YouTube video player"} |
| allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" |
| referrerPolicy="strict-origin-when-cross-origin" |
| allowFullScreen |
| loading="lazy" |
| /> |
| )} |
| </div> |
| |
| <h1 className="video-title"> |
| {activeVideo?.title ?? t.watch.defaultTitle} |
| </h1> |
| <p className="video-meta"> |
| {activeVideo |
| ? activeVideo.channel_title |
| : t.watch.defaultMeta} |
| </p> |
| |
| {activeVideo && isPlaceholderTitle(activeVideo.title, activeVideo.id) && ( |
| <p className="info-banner">{t.watch.placeholderTitleBanner}</p> |
| )} |
| |
| <div className="channel-row"> |
| <div className="channel-avatar">{channelInitial}</div> |
| <div> |
| <p className="channel-name">{activeVideo?.channel_title ?? t.watch.channelFallback}</p> |
| {activeVideo && <p className="video-meta">{t.watch.suggestedVideo}</p>} |
| </div> |
| </div> |
| |
| <div className="comments-header"> |
| <span> |
| {t.watch.commentsCount(totalComments)} |
| {toxicManual + toxicYt > 0 && ( |
| <span className="toxic-count">{t.watch.toxicDetected(toxicManual + toxicYt)}</span> |
| )} |
| </span> |
| </div> |
| |
| <div className="comment-compose"> |
| <textarea |
| value={draft} |
| onChange={(e) => setDraft(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| void handlePost(); |
| } |
| }} |
| placeholder={t.watch.composePlaceholder} |
| rows={3} |
| aria-label={t.watch.composeAriaLabel} |
| /> |
| {draft.trim() && ( |
| <div className="live-analysis"> |
| <span>{loading ? t.watch.analyzing : t.watch.liveScore}</span> |
| {result && ( |
| <> |
| <span className={`badge ${result.is_toxic ? "badge-toxic" : "badge-safe"}`}> |
| {result.is_toxic ? t.badges.toxic : t.badges.safe} |
| </span> |
| <span style={{ color: toxicityColor(result.probability) }}> |
| {`${t.watch.toxicity}: ${formatPct(result.probability)}`} |
| </span> |
| </> |
| )} |
| {error && <span className="error-text">{error}</span>} |
| </div> |
| )} |
| <div className="compose-actions"> |
| <button |
| type="button" |
| className="btn-secondary" |
| onClick={() => setDraft("")} |
| disabled={posting} |
| > |
| {t.watch.cancel} |
| </button> |
| <button |
| type="button" |
| className="btn-primary" |
| onClick={() => void handlePost()} |
| disabled={posting || !draft.trim()} |
| > |
| {posting ? t.watch.analyzing : t.watch.comment} |
| </button> |
| </div> |
| </div> |
| |
| {fetchError && <p className="error-banner">{fetchError}</p>} |
| |
| {demoBanner && !dismissDemoBanner && ( |
| <div className="info-banner dismissible"> |
| <span>{t.watch.demoBanner}</span> |
| <button |
| type="button" |
| className="btn-dismiss" |
| onClick={() => setDismissDemoBanner(true)} |
| aria-label={t.watch.dismiss} |
| > |
| × |
| </button> |
| </div> |
| )} |
| |
| {loadingVideoId && youtubeComments.length === 0 && ( |
| <p className="loading-comments">{t.watch.loadingComments}</p> |
| )} |
| |
| <div className="comment-list"> |
| {/* Persisted user comments for this video — always at the top (newest first) */} |
| {recentActivity |
| .filter((rec) => rec.source === "user_comment") |
| .map((rec, idx) => ( |
| <CommentRow |
| key={`recent-${rec.id ?? idx}`} |
| comment={{ |
| id: `recent-${rec.id ?? idx}`, |
| user: rec.author ?? randomUsername(`supa-${rec.id ?? idx}`), |
| text: truncate(rec.text, 140), |
| time: relativeTime(rec.created_at), |
| is_toxic: rec.is_toxic, |
| probability: rec.probability, |
| labels: rec.labels ?? [], |
| source: "recent", |
| }} |
| /> |
| ))} |
| {/* Optimistic local additions until refresh from Supabase completes */} |
| {[...sessionComments] |
| .reverse() |
| .filter( |
| (c) => |
| !recentActivity.some( |
| (r) => r.text === c.text && r.author === c.user, |
| ), |
| ) |
| .map((c) => ( |
| <CommentRow key={c.id} comment={c} /> |
| ))} |
| {/* YouTube fetched comments — below */} |
| {youtubeComments.map((c) => ( |
| <CommentRow key={c.id} comment={c} /> |
| ))} |
| </div> |
| |
| {recentLoading && recentActivity.length === 0 && youtubeComments.length === 0 && ( |
| <p className="loading-comments">{t.watch.loadingRecent}</p> |
| )} |
| </section> |
| |
| <SuggestedRail |
| videos={suggested} |
| activeId={activeVideo?.id ?? null} |
| loadingId={loadingVideoId} |
| onSelect={(v) => void loadVideo(v)} |
| /> |
| </div> |
| </div> |
| ); |
| } |
|
|