feat: add comment composer recent activity feed and random usernames
Browse files- frontend/src/api/client.ts +8 -0
- frontend/src/pages/WatchPage.tsx +109 -30
- frontend/src/types/api.ts +16 -1
- frontend/src/utils/toxicity.ts +61 -0
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: `
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
Cancel
|
| 200 |
</button>
|
| 201 |
-
<button
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
</button>
|
| 204 |
</div>
|
| 205 |
</div>
|
|
@@ -228,13 +285,35 @@ export function WatchPage() {
|
|
| 228 |
)}
|
| 229 |
|
| 230 |
<div className="comment-list">
|
| 231 |
-
{
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|