feat: localise the Watch flow and stop the player from auto-playing
Browse filesEvery label badge banner and aria string in the WatchPage CommentRow and SuggestedRail now resolves through the i18n dictionary so the page switches languages with the topbar toggle. The embedded iframe no longer carries the autoplay flag because users expect to press play themselves.
frontend/src/components/CommentRow.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
|
|
| 1 |
import { formatPct, toxicityColor } from "../utils/toxicity";
|
| 2 |
import type { CommentItem } from "../types/api";
|
| 3 |
|
| 4 |
type Props = { comment: CommentItem };
|
| 5 |
|
| 6 |
export function CommentRow({ comment }: Props) {
|
|
|
|
| 7 |
const color = toxicityColor(comment.probability);
|
| 8 |
return (
|
| 9 |
<div className={`comment-row ${comment.is_toxic ? "toxic" : "safe"}`}>
|
|
@@ -15,14 +17,14 @@ export function CommentRow({ comment }: Props) {
|
|
| 15 |
<span className="comment-user">@{comment.user}</span>
|
| 16 |
<span className="comment-time">{comment.time}</span>
|
| 17 |
<span className={`badge ${comment.is_toxic ? "badge-toxic" : "badge-safe"}`}>
|
| 18 |
-
{comment.is_toxic ?
|
| 19 |
</span>
|
| 20 |
<span className="comment-pct" style={{ color }}>
|
| 21 |
{formatPct(comment.probability)}
|
| 22 |
</span>
|
| 23 |
</div>
|
| 24 |
<p className="comment-text">{comment.text}</p>
|
| 25 |
-
{comment.is_toxic && <p className="flagged">
|
| 26 |
{comment.labels.length > 0 && (
|
| 27 |
<p className="comment-labels">{comment.labels.join(" · ")}</p>
|
| 28 |
)}
|
|
|
|
| 1 |
+
import { useI18n } from "../i18n/I18nContext";
|
| 2 |
import { formatPct, toxicityColor } from "../utils/toxicity";
|
| 3 |
import type { CommentItem } from "../types/api";
|
| 4 |
|
| 5 |
type Props = { comment: CommentItem };
|
| 6 |
|
| 7 |
export function CommentRow({ comment }: Props) {
|
| 8 |
+
const { t } = useI18n();
|
| 9 |
const color = toxicityColor(comment.probability);
|
| 10 |
return (
|
| 11 |
<div className={`comment-row ${comment.is_toxic ? "toxic" : "safe"}`}>
|
|
|
|
| 17 |
<span className="comment-user">@{comment.user}</span>
|
| 18 |
<span className="comment-time">{comment.time}</span>
|
| 19 |
<span className={`badge ${comment.is_toxic ? "badge-toxic" : "badge-safe"}`}>
|
| 20 |
+
{comment.is_toxic ? t.badges.toxic : t.badges.safe}
|
| 21 |
</span>
|
| 22 |
<span className="comment-pct" style={{ color }}>
|
| 23 |
{formatPct(comment.probability)}
|
| 24 |
</span>
|
| 25 |
</div>
|
| 26 |
<p className="comment-text">{comment.text}</p>
|
| 27 |
+
{comment.is_toxic && <p className="flagged">{t.watch.flagged}</p>}
|
| 28 |
{comment.labels.length > 0 && (
|
| 29 |
<p className="comment-labels">{comment.labels.join(" · ")}</p>
|
| 30 |
)}
|
frontend/src/components/SuggestedRail.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import type { SuggestedVideo } from "../types/api";
|
| 2 |
|
| 3 |
type Props = {
|
|
@@ -8,9 +9,10 @@ type Props = {
|
|
| 8 |
};
|
| 9 |
|
| 10 |
export function SuggestedRail({ videos, activeId, loadingId, onSelect }: Props) {
|
|
|
|
| 11 |
return (
|
| 12 |
<aside className="suggested-rail">
|
| 13 |
-
<p className="rail-title">
|
| 14 |
{videos.map((v) => (
|
| 15 |
<button
|
| 16 |
key={v.id}
|
|
@@ -34,8 +36,8 @@ export function SuggestedRail({ videos, activeId, loadingId, onSelect }: Props)
|
|
| 34 |
<div className="suggested-info">
|
| 35 |
<p className="suggested-title">{v.title}</p>
|
| 36 |
<p className="suggested-channel">{v.channel_title}</p>
|
| 37 |
-
{!v.embeddable && <span className="embed-badge">
|
| 38 |
-
{loadingId === v.id && <span className="loading-tag">
|
| 39 |
</div>
|
| 40 |
</button>
|
| 41 |
))}
|
|
|
|
| 1 |
+
import { useI18n } from "../i18n/I18nContext";
|
| 2 |
import type { SuggestedVideo } from "../types/api";
|
| 3 |
|
| 4 |
type Props = {
|
|
|
|
| 9 |
};
|
| 10 |
|
| 11 |
export function SuggestedRail({ videos, activeId, loadingId, onSelect }: Props) {
|
| 12 |
+
const { t } = useI18n();
|
| 13 |
return (
|
| 14 |
<aside className="suggested-rail">
|
| 15 |
+
<p className="rail-title">{t.watch.upNext}</p>
|
| 16 |
{videos.map((v) => (
|
| 17 |
<button
|
| 18 |
key={v.id}
|
|
|
|
| 36 |
<div className="suggested-info">
|
| 37 |
<p className="suggested-title">{v.title}</p>
|
| 38 |
<p className="suggested-channel">{v.channel_title}</p>
|
| 39 |
+
{!v.embeddable && <span className="embed-badge">{t.watch.externalOnly}</span>}
|
| 40 |
+
{loadingId === v.id && <span className="loading-tag">{t.watch.loadingComments}</span>}
|
| 41 |
</div>
|
| 42 |
</button>
|
| 43 |
))}
|
frontend/src/pages/WatchPage.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { CommentRow } from "../components/CommentRow";
|
|
| 9 |
import { SuggestedRail } from "../components/SuggestedRail";
|
| 10 |
import { useApp } from "../context/AppContext";
|
| 11 |
import { useDebouncedPredict } from "../hooks/useDebouncedPredict";
|
|
|
|
| 12 |
import type {
|
| 13 |
CommentItem,
|
| 14 |
PredictionRecord,
|
|
@@ -30,6 +31,7 @@ function isPlaceholderTitle(title: string, id: string): boolean {
|
|
| 30 |
}
|
| 31 |
|
| 32 |
export function WatchPage() {
|
|
|
|
| 33 |
const { threshold, addHubEntry } = useApp();
|
| 34 |
const [draft, setDraft] = useState("");
|
| 35 |
const [sessionComments, setSessionComments] = useState<CommentItem[]>([]);
|
|
@@ -74,7 +76,7 @@ export function WatchPage() {
|
|
| 74 |
void loadVideo(r.videos[0]);
|
| 75 |
}
|
| 76 |
})
|
| 77 |
-
.catch(() => setFetchError(
|
| 78 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 79 |
}, []);
|
| 80 |
|
|
@@ -86,9 +88,9 @@ export function WatchPage() {
|
|
| 86 |
const analysis = result ?? (await predict(text, threshold));
|
| 87 |
const item: CommentItem = {
|
| 88 |
id: newId(),
|
| 89 |
-
user:
|
| 90 |
text,
|
| 91 |
-
time:
|
| 92 |
is_toxic: analysis.is_toxic,
|
| 93 |
probability: analysis.probability,
|
| 94 |
labels: analysis.labels,
|
|
@@ -96,17 +98,19 @@ export function WatchPage() {
|
|
| 96 |
};
|
| 97 |
setSessionComments((prev) => [...prev, item]);
|
| 98 |
addHubEntry({
|
| 99 |
-
user:
|
| 100 |
snippet: text.slice(0, 45),
|
| 101 |
score: analysis.probability,
|
| 102 |
-
action: analysis.is_toxic
|
|
|
|
|
|
|
| 103 |
});
|
| 104 |
setDraft("");
|
| 105 |
void refreshRecent(activeVideo?.id);
|
| 106 |
} finally {
|
| 107 |
setPosting(false);
|
| 108 |
}
|
| 109 |
-
}, [draft, posting, result, threshold, addHubEntry, refreshRecent, activeVideo?.id]);
|
| 110 |
|
| 111 |
const loadVideo = async (video: SuggestedVideo) => {
|
| 112 |
setActiveVideo(video);
|
|
@@ -123,7 +127,7 @@ export function WatchPage() {
|
|
| 123 |
id: `yt-${video.id}-${i}`,
|
| 124 |
user: randomUsername(`yt-${video.id}-${i}`),
|
| 125 |
text: r.text,
|
| 126 |
-
time:
|
| 127 |
is_toxic: r.is_toxic,
|
| 128 |
probability: r.probability,
|
| 129 |
labels: r.labels,
|
|
@@ -131,7 +135,7 @@ export function WatchPage() {
|
|
| 131 |
}))
|
| 132 |
);
|
| 133 |
} catch (e) {
|
| 134 |
-
setFetchError(e instanceof Error ? e.message :
|
| 135 |
setYoutubeComments([]);
|
| 136 |
setDemoBanner(false);
|
| 137 |
} finally {
|
|
@@ -162,16 +166,16 @@ export function WatchPage() {
|
|
| 162 |
alt=""
|
| 163 |
className="player-fallback-thumb"
|
| 164 |
/>
|
| 165 |
-
<span className="player-fallback-cta">
|
| 166 |
</a>
|
| 167 |
) : (
|
| 168 |
<iframe
|
| 169 |
className="player-iframe"
|
| 170 |
src={`https://www.youtube.com/embed/${
|
| 171 |
activeVideo?.id ?? DEFAULT_EMBED_VIDEO_ID
|
| 172 |
-
}?rel=0
|
| 173 |
title={activeVideo?.title ?? "YouTube video player"}
|
| 174 |
-
allow="accelerometer;
|
| 175 |
referrerPolicy="strict-origin-when-cross-origin"
|
| 176 |
allowFullScreen
|
| 177 |
loading="lazy"
|
|
@@ -180,33 +184,31 @@ export function WatchPage() {
|
|
| 180 |
</div>
|
| 181 |
|
| 182 |
<h1 className="video-title">
|
| 183 |
-
{activeVideo?.title ??
|
| 184 |
</h1>
|
| 185 |
<p className="video-meta">
|
| 186 |
{activeVideo
|
| 187 |
? activeVideo.channel_title
|
| 188 |
-
:
|
| 189 |
</p>
|
| 190 |
|
| 191 |
{activeVideo && isPlaceholderTitle(activeVideo.title, activeVideo.id) && (
|
| 192 |
-
<p className="info-banner">
|
| 193 |
-
Demo metadata — add <code>YOUTUBE_API_KEY</code> to <code>.env</code> for real titles.
|
| 194 |
-
</p>
|
| 195 |
)}
|
| 196 |
|
| 197 |
<div className="channel-row">
|
| 198 |
<div className="channel-avatar">{channelInitial}</div>
|
| 199 |
<div>
|
| 200 |
-
<p className="channel-name">{activeVideo?.channel_title ??
|
| 201 |
-
{activeVideo && <p className="video-meta">
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
|
| 205 |
<div className="comments-header">
|
| 206 |
<span>
|
| 207 |
-
{totalComments}
|
| 208 |
{toxicManual + toxicYt > 0 && (
|
| 209 |
-
<span className="toxic-count">
|
| 210 |
)}
|
| 211 |
</span>
|
| 212 |
</div>
|
|
@@ -221,20 +223,20 @@ export function WatchPage() {
|
|
| 221 |
void handlePost();
|
| 222 |
}
|
| 223 |
}}
|
| 224 |
-
placeholder=
|
| 225 |
rows={3}
|
| 226 |
-
aria-label=
|
| 227 |
/>
|
| 228 |
{draft.trim() && (
|
| 229 |
<div className="live-analysis">
|
| 230 |
-
<span>{loading ?
|
| 231 |
{result && (
|
| 232 |
<>
|
| 233 |
<span className={`badge ${result.is_toxic ? "badge-toxic" : "badge-safe"}`}>
|
| 234 |
-
{result.
|
| 235 |
</span>
|
| 236 |
<span style={{ color: toxicityColor(result.probability) }}>
|
| 237 |
-
|
| 238 |
</span>
|
| 239 |
</>
|
| 240 |
)}
|
|
@@ -248,7 +250,7 @@ export function WatchPage() {
|
|
| 248 |
onClick={() => setDraft("")}
|
| 249 |
disabled={posting}
|
| 250 |
>
|
| 251 |
-
|
| 252 |
</button>
|
| 253 |
<button
|
| 254 |
type="button"
|
|
@@ -256,7 +258,7 @@ export function WatchPage() {
|
|
| 256 |
onClick={() => void handlePost()}
|
| 257 |
disabled={posting || !draft.trim()}
|
| 258 |
>
|
| 259 |
-
{posting ?
|
| 260 |
</button>
|
| 261 |
</div>
|
| 262 |
</div>
|
|
@@ -265,15 +267,12 @@ export function WatchPage() {
|
|
| 265 |
|
| 266 |
{demoBanner && !dismissDemoBanner && (
|
| 267 |
<div className="info-banner dismissible">
|
| 268 |
-
<span>
|
| 269 |
-
Using demo comments — add <code>YOUTUBE_API_KEY</code> to <code>.env</code> for real
|
| 270 |
-
YouTube threads.
|
| 271 |
-
</span>
|
| 272 |
<button
|
| 273 |
type="button"
|
| 274 |
className="btn-dismiss"
|
| 275 |
onClick={() => setDismissDemoBanner(true)}
|
| 276 |
-
aria-label=
|
| 277 |
>
|
| 278 |
×
|
| 279 |
</button>
|
|
@@ -281,7 +280,7 @@ export function WatchPage() {
|
|
| 281 |
)}
|
| 282 |
|
| 283 |
{loadingVideoId && youtubeComments.length === 0 && (
|
| 284 |
-
<p className="loading-comments">
|
| 285 |
)}
|
| 286 |
|
| 287 |
<div className="comment-list">
|
|
@@ -312,7 +311,7 @@ export function WatchPage() {
|
|
| 312 |
</div>
|
| 313 |
|
| 314 |
{recentLoading && recentActivity.length === 0 && youtubeComments.length === 0 && (
|
| 315 |
-
<p className="loading-comments">
|
| 316 |
)}
|
| 317 |
</section>
|
| 318 |
|
|
|
|
| 9 |
import { SuggestedRail } from "../components/SuggestedRail";
|
| 10 |
import { useApp } from "../context/AppContext";
|
| 11 |
import { useDebouncedPredict } from "../hooks/useDebouncedPredict";
|
| 12 |
+
import { useI18n } from "../i18n/I18nContext";
|
| 13 |
import type {
|
| 14 |
CommentItem,
|
| 15 |
PredictionRecord,
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
export function WatchPage() {
|
| 34 |
+
const { t } = useI18n();
|
| 35 |
const { threshold, addHubEntry } = useApp();
|
| 36 |
const [draft, setDraft] = useState("");
|
| 37 |
const [sessionComments, setSessionComments] = useState<CommentItem[]>([]);
|
|
|
|
| 76 |
void loadVideo(r.videos[0]);
|
| 77 |
}
|
| 78 |
})
|
| 79 |
+
.catch(() => setFetchError(t.watch.couldNotLoadVideos));
|
| 80 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 81 |
}, []);
|
| 82 |
|
|
|
|
| 88 |
const analysis = result ?? (await predict(text, threshold));
|
| 89 |
const item: CommentItem = {
|
| 90 |
id: newId(),
|
| 91 |
+
user: t.watch.you,
|
| 92 |
text,
|
| 93 |
+
time: t.watch.justNow,
|
| 94 |
is_toxic: analysis.is_toxic,
|
| 95 |
probability: analysis.probability,
|
| 96 |
labels: analysis.labels,
|
|
|
|
| 98 |
};
|
| 99 |
setSessionComments((prev) => [...prev, item]);
|
| 100 |
addHubEntry({
|
| 101 |
+
user: `@${t.watch.you}`,
|
| 102 |
snippet: text.slice(0, 45),
|
| 103 |
score: analysis.probability,
|
| 104 |
+
action: analysis.is_toxic
|
| 105 |
+
? `${t.watch.posted} (${t.badges.toxic.toLowerCase()})`
|
| 106 |
+
: t.badges.safe,
|
| 107 |
});
|
| 108 |
setDraft("");
|
| 109 |
void refreshRecent(activeVideo?.id);
|
| 110 |
} finally {
|
| 111 |
setPosting(false);
|
| 112 |
}
|
| 113 |
+
}, [draft, posting, result, threshold, addHubEntry, refreshRecent, activeVideo?.id, t]);
|
| 114 |
|
| 115 |
const loadVideo = async (video: SuggestedVideo) => {
|
| 116 |
setActiveVideo(video);
|
|
|
|
| 127 |
id: `yt-${video.id}-${i}`,
|
| 128 |
user: randomUsername(`yt-${video.id}-${i}`),
|
| 129 |
text: r.text,
|
| 130 |
+
time: t.watch.fromYoutube,
|
| 131 |
is_toxic: r.is_toxic,
|
| 132 |
probability: r.probability,
|
| 133 |
labels: r.labels,
|
|
|
|
| 135 |
}))
|
| 136 |
);
|
| 137 |
} catch (e) {
|
| 138 |
+
setFetchError(e instanceof Error ? e.message : t.watch.failedToLoadComments);
|
| 139 |
setYoutubeComments([]);
|
| 140 |
setDemoBanner(false);
|
| 141 |
} finally {
|
|
|
|
| 166 |
alt=""
|
| 167 |
className="player-fallback-thumb"
|
| 168 |
/>
|
| 169 |
+
<span className="player-fallback-cta">{t.watch.watchOnYoutube}</span>
|
| 170 |
</a>
|
| 171 |
) : (
|
| 172 |
<iframe
|
| 173 |
className="player-iframe"
|
| 174 |
src={`https://www.youtube.com/embed/${
|
| 175 |
activeVideo?.id ?? DEFAULT_EMBED_VIDEO_ID
|
| 176 |
+
}?rel=0`}
|
| 177 |
title={activeVideo?.title ?? "YouTube video player"}
|
| 178 |
+
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
| 179 |
referrerPolicy="strict-origin-when-cross-origin"
|
| 180 |
allowFullScreen
|
| 181 |
loading="lazy"
|
|
|
|
| 184 |
</div>
|
| 185 |
|
| 186 |
<h1 className="video-title">
|
| 187 |
+
{activeVideo?.title ?? t.watch.defaultTitle}
|
| 188 |
</h1>
|
| 189 |
<p className="video-meta">
|
| 190 |
{activeVideo
|
| 191 |
? activeVideo.channel_title
|
| 192 |
+
: t.watch.defaultMeta}
|
| 193 |
</p>
|
| 194 |
|
| 195 |
{activeVideo && isPlaceholderTitle(activeVideo.title, activeVideo.id) && (
|
| 196 |
+
<p className="info-banner">{t.watch.placeholderTitleBanner}</p>
|
|
|
|
|
|
|
| 197 |
)}
|
| 198 |
|
| 199 |
<div className="channel-row">
|
| 200 |
<div className="channel-avatar">{channelInitial}</div>
|
| 201 |
<div>
|
| 202 |
+
<p className="channel-name">{activeVideo?.channel_title ?? t.watch.channelFallback}</p>
|
| 203 |
+
{activeVideo && <p className="video-meta">{t.watch.suggestedVideo}</p>}
|
| 204 |
</div>
|
| 205 |
</div>
|
| 206 |
|
| 207 |
<div className="comments-header">
|
| 208 |
<span>
|
| 209 |
+
{t.watch.commentsCount(totalComments)}
|
| 210 |
{toxicManual + toxicYt > 0 && (
|
| 211 |
+
<span className="toxic-count">{t.watch.toxicDetected(toxicManual + toxicYt)}</span>
|
| 212 |
)}
|
| 213 |
</span>
|
| 214 |
</div>
|
|
|
|
| 223 |
void handlePost();
|
| 224 |
}
|
| 225 |
}}
|
| 226 |
+
placeholder={t.watch.composePlaceholder}
|
| 227 |
rows={3}
|
| 228 |
+
aria-label={t.watch.composeAriaLabel}
|
| 229 |
/>
|
| 230 |
{draft.trim() && (
|
| 231 |
<div className="live-analysis">
|
| 232 |
+
<span>{loading ? t.watch.analyzing : t.watch.liveScore}</span>
|
| 233 |
{result && (
|
| 234 |
<>
|
| 235 |
<span className={`badge ${result.is_toxic ? "badge-toxic" : "badge-safe"}`}>
|
| 236 |
+
{result.is_toxic ? t.badges.toxic : t.badges.safe}
|
| 237 |
</span>
|
| 238 |
<span style={{ color: toxicityColor(result.probability) }}>
|
| 239 |
+
{`${t.watch.toxicity}: ${formatPct(result.probability)}`}
|
| 240 |
</span>
|
| 241 |
</>
|
| 242 |
)}
|
|
|
|
| 250 |
onClick={() => setDraft("")}
|
| 251 |
disabled={posting}
|
| 252 |
>
|
| 253 |
+
{t.watch.cancel}
|
| 254 |
</button>
|
| 255 |
<button
|
| 256 |
type="button"
|
|
|
|
| 258 |
onClick={() => void handlePost()}
|
| 259 |
disabled={posting || !draft.trim()}
|
| 260 |
>
|
| 261 |
+
{posting ? t.watch.analyzing : t.watch.comment}
|
| 262 |
</button>
|
| 263 |
</div>
|
| 264 |
</div>
|
|
|
|
| 267 |
|
| 268 |
{demoBanner && !dismissDemoBanner && (
|
| 269 |
<div className="info-banner dismissible">
|
| 270 |
+
<span>{t.watch.demoBanner}</span>
|
|
|
|
|
|
|
|
|
|
| 271 |
<button
|
| 272 |
type="button"
|
| 273 |
className="btn-dismiss"
|
| 274 |
onClick={() => setDismissDemoBanner(true)}
|
| 275 |
+
aria-label={t.watch.dismiss}
|
| 276 |
>
|
| 277 |
×
|
| 278 |
</button>
|
|
|
|
| 280 |
)}
|
| 281 |
|
| 282 |
{loadingVideoId && youtubeComments.length === 0 && (
|
| 283 |
+
<p className="loading-comments">{t.watch.loadingComments}</p>
|
| 284 |
)}
|
| 285 |
|
| 286 |
<div className="comment-list">
|
|
|
|
| 311 |
</div>
|
| 312 |
|
| 313 |
{recentLoading && recentActivity.length === 0 && youtubeComments.length === 0 && (
|
| 314 |
+
<p className="loading-comments">{t.watch.loadingRecent}</p>
|
| 315 |
)}
|
| 316 |
</section>
|
| 317 |
|