Ruperth commited on
Commit
38ee3c1
·
1 Parent(s): 5d39332

feat: localise the Watch flow and stop the player from auto-playing

Browse files

Every 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 ? "Toxic" : "Safe"}
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">Flagged for review</p>}
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">Up next</p>
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">External only</span>}
38
- {loadingId === v.id && <span className="loading-tag">Loading comments…</span>}
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("Could not load suggested videos"));
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: "you",
90
  text,
91
- time: "just now",
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: "@you",
100
  snippet: text.slice(0, 45),
101
  score: analysis.probability,
102
- action: analysis.is_toxic ? "Posted (toxic)" : "Approved",
 
 
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: "from YouTube",
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 : "Failed to load comments");
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">Watch on YouTube (embedding blocked)</span>
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${activeVideo ? "&autoplay=1" : ""}`}
173
  title={activeVideo?.title ?? "YouTube video player"}
174
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
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 ?? "Watch and moderate comments"}
184
  </h1>
185
  <p className="video-meta">
186
  {activeVideo
187
  ? activeVideo.channel_title
188
- : "Choose a video from Up next to load and score its comments"}
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 ?? "YouTube"}</p>
201
- {activeVideo && <p className="video-meta">Suggested video</p>}
202
  </div>
203
  </div>
204
 
205
  <div className="comments-header">
206
  <span>
207
- {totalComments} comments
208
  {toxicManual + toxicYt > 0 && (
209
- <span className="toxic-count"> · {toxicManual + toxicYt} toxic detected</span>
210
  )}
211
  </span>
212
  </div>
@@ -221,20 +223,20 @@ export function WatchPage() {
221
  void handlePost();
222
  }
223
  }}
224
- placeholder="Add a comment…"
225
  rows={3}
226
- aria-label="Write a comment"
227
  />
228
  {draft.trim() && (
229
  <div className="live-analysis">
230
- <span>{loading ? "Analyzing…" : "Live score"}</span>
231
  {result && (
232
  <>
233
  <span className={`badge ${result.is_toxic ? "badge-toxic" : "badge-safe"}`}>
234
- {result.status}
235
  </span>
236
  <span style={{ color: toxicityColor(result.probability) }}>
237
- Toxicity: {formatPct(result.probability)}
238
  </span>
239
  </>
240
  )}
@@ -248,7 +250,7 @@ export function WatchPage() {
248
  onClick={() => setDraft("")}
249
  disabled={posting}
250
  >
251
- Cancel
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 ? "Analyzing…" : "Comment"}
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="Dismiss"
277
  >
278
  ×
279
  </button>
@@ -281,7 +280,7 @@ export function WatchPage() {
281
  )}
282
 
283
  {loadingVideoId && youtubeComments.length === 0 && (
284
- <p className="loading-comments">Loading comments…</p>
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">Loading recent comments…</p>
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