Ruperth commited on
Commit
7e7e06a
·
1 Parent(s): 46490f6

feat: add comment composer recent activity feed and random usernames

Browse files
frontend/src/api/client.ts CHANGED
@@ -1,6 +1,7 @@
1
  import type {
2
  ModelStatusEntry,
3
  PredictResponse,
 
4
  SuggestedVideo,
5
  VideoResponse,
6
  } from "../types/api";
@@ -85,6 +86,13 @@ export function getSuggestedVideos() {
85
  return request<{ videos: SuggestedVideo[]; max_comments: number }>("/videos/suggested");
86
  }
87
 
 
 
 
 
 
 
 
88
  export function getModelInfo() {
89
  return request<{
90
  name: string;
 
1
  import type {
2
  ModelStatusEntry,
3
  PredictResponse,
4
+ PredictionsListResponse,
5
  SuggestedVideo,
6
  VideoResponse,
7
  } from "../types/api";
 
86
  return request<{ videos: SuggestedVideo[]; max_comments: number }>("/videos/suggested");
87
  }
88
 
89
+ export function listPredictions(videoId?: string, limit = 20) {
90
+ const params = new URLSearchParams();
91
+ if (videoId) params.set("video_id", videoId);
92
+ params.set("limit", String(limit));
93
+ return request<PredictionsListResponse>(`/predictions?${params.toString()}`);
94
+ }
95
+
96
  export function getModelInfo() {
97
  return request<{
98
  name: string;
frontend/src/pages/WatchPage.tsx CHANGED
@@ -1,11 +1,27 @@
1
  import { useCallback, useEffect, useState } from "react";
2
- import { getSuggestedVideos, predict, predictVideo } from "../api/client";
 
 
 
 
 
3
  import { CommentRow } from "../components/CommentRow";
4
  import { SuggestedRail } from "../components/SuggestedRail";
5
  import { useApp } from "../context/AppContext";
6
  import { useDebouncedPredict } from "../hooks/useDebouncedPredict";
7
- import type { CommentItem, SuggestedVideo } from "../types/api";
8
- import { formatPct, newId, toxicityColor } from "../utils/toxicity";
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  const DEFAULT_EMBED_VIDEO_ID = "A1uxPRUgimk";
11
 
@@ -25,41 +41,72 @@ export function WatchPage() {
25
  const [fetchError, setFetchError] = useState<string | null>(null);
26
  const [demoBanner, setDemoBanner] = useState(false);
27
  const [dismissDemoBanner, setDismissDemoBanner] = useState(false);
 
 
 
28
 
29
  const { result, loading, error } = useDebouncedPredict(draft, threshold);
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  useEffect(() => {
32
  getSuggestedVideos()
33
  .then((r) => {
34
  setSuggested(r.videos);
35
  setMaxComments(r.max_comments);
 
 
 
 
36
  })
37
  .catch(() => setFetchError("Could not load suggested videos"));
 
38
  }, []);
39
 
40
  const handlePost = useCallback(async () => {
41
  const text = draft.trim();
42
- if (!text) return;
43
- const analysis = result ?? (await predict(text, threshold));
44
- const item: CommentItem = {
45
- id: newId(),
46
- user: "you",
47
- text,
48
- time: "just now",
49
- is_toxic: analysis.is_toxic,
50
- probability: analysis.probability,
51
- labels: analysis.labels,
52
- source: "manual",
53
- };
54
- setSessionComments((prev) => [...prev, item]);
55
- addHubEntry({
56
- user: "@you",
57
- snippet: text.slice(0, 45),
58
- score: analysis.probability,
59
- action: analysis.is_toxic ? "Posted (toxic)" : "Approved",
60
- });
61
- setDraft("");
62
- }, [draft, result, threshold, addHubEntry]);
 
 
 
 
 
 
63
 
64
  const loadVideo = async (video: SuggestedVideo) => {
65
  setActiveVideo(video);
@@ -74,7 +121,7 @@ export function WatchPage() {
74
  setYoutubeComments(
75
  res.results.map((r, i) => ({
76
  id: `yt-${video.id}-${i}`,
77
- user: `viewer_${i + 1}`,
78
  text: r.text,
79
  time: "from YouTube",
80
  is_toxic: r.is_toxic,
@@ -195,11 +242,21 @@ export function WatchPage() {
195
  </div>
196
  )}
197
  <div className="compose-actions">
198
- <button type="button" className="btn-secondary" onClick={() => setDraft("")}>
 
 
 
 
 
199
  Cancel
200
  </button>
201
- <button type="button" className="btn-primary" onClick={() => void handlePost()}>
202
- Comment
 
 
 
 
 
203
  </button>
204
  </div>
205
  </div>
@@ -228,13 +285,35 @@ export function WatchPage() {
228
  )}
229
 
230
  <div className="comment-list">
231
- {youtubeComments.map((c) => (
232
- <CommentRow key={c.id} comment={c} />
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  ))}
 
234
  {[...sessionComments].reverse().map((c) => (
235
  <CommentRow key={c.id} comment={c} />
236
  ))}
 
 
 
 
237
  </div>
 
 
 
 
238
  </section>
239
 
240
  <SuggestedRail
 
1
  import { useCallback, useEffect, useState } from "react";
2
+ import {
3
+ getSuggestedVideos,
4
+ listPredictions,
5
+ predict,
6
+ predictVideo,
7
+ } from "../api/client";
8
  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,
15
+ SuggestedVideo,
16
+ } from "../types/api";
17
+ import {
18
+ formatPct,
19
+ newId,
20
+ randomUsername,
21
+ relativeTime,
22
+ toxicityColor,
23
+ truncate,
24
+ } from "../utils/toxicity";
25
 
26
  const DEFAULT_EMBED_VIDEO_ID = "A1uxPRUgimk";
27
 
 
41
  const [fetchError, setFetchError] = useState<string | null>(null);
42
  const [demoBanner, setDemoBanner] = useState(false);
43
  const [dismissDemoBanner, setDismissDemoBanner] = useState(false);
44
+ const [posting, setPosting] = useState(false);
45
+ const [recentActivity, setRecentActivity] = useState<PredictionRecord[]>([]);
46
+ const [recentLoading, setRecentLoading] = useState(false);
47
 
48
  const { result, loading, error } = useDebouncedPredict(draft, threshold);
49
 
50
+ const refreshRecent = useCallback(async (videoId?: string) => {
51
+ setRecentLoading(true);
52
+ try {
53
+ const res = await listPredictions(videoId, 20);
54
+ setRecentActivity(Array.isArray(res?.predictions) ? res.predictions : []);
55
+ } catch {
56
+ // Degrade gracefully if endpoint is missing or DB not configured
57
+ setRecentActivity([]);
58
+ } finally {
59
+ setRecentLoading(false);
60
+ }
61
+ }, []);
62
+
63
+ useEffect(() => {
64
+ void refreshRecent(activeVideo?.id);
65
+ }, [activeVideo?.id, refreshRecent]);
66
+
67
  useEffect(() => {
68
  getSuggestedVideos()
69
  .then((r) => {
70
  setSuggested(r.videos);
71
  setMaxComments(r.max_comments);
72
+ // Auto-load first video so the user sees comments on initial render.
73
+ if (r.videos.length > 0) {
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
 
81
  const handlePost = useCallback(async () => {
82
  const text = draft.trim();
83
+ if (!text || posting) return;
84
+ setPosting(true);
85
+ try {
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,
95
+ source: "manual",
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);
 
121
  setYoutubeComments(
122
  res.results.map((r, i) => ({
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,
 
242
  </div>
243
  )}
244
  <div className="compose-actions">
245
+ <button
246
+ type="button"
247
+ className="btn-secondary"
248
+ onClick={() => setDraft("")}
249
+ disabled={posting}
250
+ >
251
  Cancel
252
  </button>
253
+ <button
254
+ type="button"
255
+ className="btn-primary"
256
+ onClick={() => void handlePost()}
257
+ disabled={posting || !draft.trim()}
258
+ >
259
+ {posting ? "Analyzing…" : "Comment"}
260
  </button>
261
  </div>
262
  </div>
 
285
  )}
286
 
287
  <div className="comment-list">
288
+ {/* Local Supabase comments — always at the top (newest first) */}
289
+ {recentActivity.map((rec, idx) => (
290
+ <CommentRow
291
+ key={`recent-${rec.id ?? idx}`}
292
+ comment={{
293
+ id: `recent-${rec.id ?? idx}`,
294
+ user: randomUsername(`supa-${rec.id ?? idx}`),
295
+ text: truncate(rec.text, 140),
296
+ time: relativeTime(rec.created_at),
297
+ is_toxic: rec.is_toxic,
298
+ probability: rec.probability,
299
+ labels: rec.labels ?? [],
300
+ source: "recent",
301
+ }}
302
+ />
303
  ))}
304
+ {/* Current session (just posted) */}
305
  {[...sessionComments].reverse().map((c) => (
306
  <CommentRow key={c.id} comment={c} />
307
  ))}
308
+ {/* YouTube fetched comments — below */}
309
+ {youtubeComments.map((c) => (
310
+ <CommentRow key={c.id} comment={c} />
311
+ ))}
312
  </div>
313
+
314
+ {recentLoading && recentActivity.length === 0 && youtubeComments.length === 0 && (
315
+ <p className="loading-comments">Loading recent comments…</p>
316
+ )}
317
  </section>
318
 
319
  <SuggestedRail
frontend/src/types/api.ts CHANGED
@@ -43,5 +43,20 @@ export type CommentItem = {
43
  is_toxic: boolean;
44
  probability: number;
45
  labels: string[];
46
- source: "manual" | "youtube";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  };
 
43
  is_toxic: boolean;
44
  probability: number;
45
  labels: string[];
46
+ source: "manual" | "youtube" | "recent";
47
+ };
48
+
49
+ export type PredictionRecord = {
50
+ id: string | number;
51
+ text: string;
52
+ is_toxic: boolean;
53
+ probability: number;
54
+ video_id?: string | null;
55
+ created_at: string;
56
+ labels?: string[];
57
+ };
58
+
59
+ export type PredictionsListResponse = {
60
+ predictions: PredictionRecord[];
61
+ total?: number;
62
  };
frontend/src/utils/toxicity.ts CHANGED
@@ -12,3 +12,64 @@ export function formatPct(probability: number): string {
12
  export function newId(): string {
13
  return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
14
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  export function newId(): string {
13
  return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
14
  }
15
+
16
+ const RTF = new Intl.RelativeTimeFormat("es", { numeric: "auto" });
17
+
18
+ const TIME_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
19
+ ["year", 60 * 60 * 24 * 365],
20
+ ["month", 60 * 60 * 24 * 30],
21
+ ["day", 60 * 60 * 24],
22
+ ["hour", 60 * 60],
23
+ ["minute", 60],
24
+ ["second", 1],
25
+ ];
26
+
27
+ export function relativeTime(input: string | number | Date): string {
28
+ const date = input instanceof Date ? input : new Date(input);
29
+ const seconds = (date.getTime() - Date.now()) / 1000;
30
+ if (!Number.isFinite(seconds)) return "";
31
+ for (const [unit, secondsInUnit] of TIME_UNITS) {
32
+ if (Math.abs(seconds) >= secondsInUnit || unit === "second") {
33
+ return RTF.format(Math.round(seconds / secondsInUnit), unit);
34
+ }
35
+ }
36
+ return "";
37
+ }
38
+
39
+ export function truncate(text: string, max = 140): string {
40
+ if (text.length <= max) return text;
41
+ return `${text.slice(0, max - 1).trimEnd()}…`;
42
+ }
43
+
44
+ const ADJECTIVES = [
45
+ "happy", "lucky", "wild", "calm", "bright", "swift", "quiet", "lazy",
46
+ "brave", "fuzzy", "shiny", "sleepy", "quick", "noble", "loud", "humble",
47
+ "stormy", "sunny", "rainy", "frosty", "spicy", "salty", "sweet", "crazy",
48
+ "mighty", "silent", "epic", "cosmic", "rusty", "neon", "wandering", "lone",
49
+ ];
50
+
51
+ const NOUNS = [
52
+ "panda", "tiger", "falcon", "otter", "eagle", "wolf", "fox", "bear",
53
+ "lynx", "shark", "raven", "viper", "phoenix", "dragon", "jaguar", "koala",
54
+ "moose", "lion", "owl", "octopus", "rabbit", "hawk", "badger", "robin",
55
+ "cosmonaut", "drifter", "ninja", "wizard", "rider", "pilot", "skater", "gamer",
56
+ ];
57
+
58
+ const SUFFIXES = ["", "_", "_", "_yt", "_hd", "_real", "99", "01", "_xd", "_v2", "_official", ""];
59
+
60
+ /**
61
+ * Generate a YouTube-style random username, deterministic per seed.
62
+ */
63
+ export function randomUsername(seed: string | number | undefined | null): string {
64
+ const str = seed == null ? "anon" : String(seed);
65
+ let hash = 5381;
66
+ for (let i = 0; i < str.length; i++) {
67
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
68
+ }
69
+ const adj = ADJECTIVES[hash % ADJECTIVES.length] ?? "anon";
70
+ const noun = NOUNS[Math.floor(hash / 32) % NOUNS.length] ?? "user";
71
+ const suffix = SUFFIXES[Math.floor(hash / 1024) % SUFFIXES.length] ?? "";
72
+ const needsNum = suffix === "" || suffix.endsWith("_");
73
+ const num = needsNum ? String(hash % 1000) : "";
74
+ return `${adj}_${noun}${suffix}${num}`;
75
+ }