v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -4,11 +4,27 @@ import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now();
} catch {
return true;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(() => {
const stored = localStorage.getItem("authResponse");
if (!stored) return null;
return stored ? deserializeAuthResponse(JSON.parse(stored)) : null;
const parsed = deserializeAuthResponse(JSON.parse(stored));
if (isTokenExpired(parsed.token)) {
localStorage.removeItem("authResponse");
return null;
}
return parsed;
});
const value: AuthContextValue = { authResponse, setAuthResponse };

View File

@@ -0,0 +1,19 @@
import { createContext } from "react";
export interface PlayerItem {
embedUrl: string;
title?: string;
type: string;
}
export interface PlayerContextValue {
current: PlayerItem | null;
play(item: PlayerItem): void;
stop(): void;
}
export const PlayerContext = createContext<PlayerContextValue>({
current: null,
play: () => {},
stop: () => {},
});

View File

@@ -0,0 +1,14 @@
import { useState } from "react";
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
export function PlayerProvider({ children }: { children: React.ReactNode }) {
const [current, setCurrent] = useState<PlayerItem | null>(null);
return (
<PlayerContext.Provider
value={{ current, play: setCurrent, stop: () => setCurrent(null) }}
>
{children}
</PlayerContext.Provider>
);
}

View File

@@ -1,5 +1,5 @@
import { createContext } from "react";
import type { Dump, OnlineUser, Playlist } from "../model.ts";
import type { Comment, Dump, OnlineUser, Playlist } from "../model.ts";
export interface VoteEvent {
dumpId: string;
@@ -15,6 +15,13 @@ export interface PlaylistEvent {
dumpIds?: string[];
}
export interface CommentEvent {
type: "created" | "deleted";
dumpId: string;
comment?: Comment;
commentId?: string;
}
export interface WSContextValue {
onlineUsers: OnlineUser[];
voteCounts: Record<string, number>;
@@ -22,8 +29,10 @@ export interface WSContextValue {
recentDumps: Dump[];
deletedDumpIds: Set<string>;
lastVoteEvent: VoteEvent | null;
lastDumpEvent: Dump | null;
lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>;
lastCommentEvent: CommentEvent | null;
castVote: (dumpId: string) => void;
removeVote: (dumpId: string) => void;
}
@@ -35,8 +44,10 @@ export const WSContext = createContext<WSContextValue>({
recentDumps: [],
deletedDumpIds: new Set(),
lastVoteEvent: null,
lastDumpEvent: null,
lastPlaylistEvent: null,
deletedPlaylistIds: new Set(),
lastCommentEvent: null,
castVote: () => {},
removeVote: () => {},
});

View File

@@ -7,14 +7,25 @@ import {
useState,
} from "react";
import {
type CommentEvent,
type PlaylistEvent,
type VoteEvent,
WSContext,
type WSContextValue,
} from "./WSContext.ts";
import { WS_URL } from "../config/api.ts";
import type { Dump, OnlineUser, RawDump, RawPlaylist } from "../model.ts";
import { deserializeDump, deserializePlaylist } from "../model.ts";
import type {
Dump,
OnlineUser,
RawComment,
RawDump,
RawPlaylist,
} from "../model.ts";
import {
deserializeComment,
deserializeDump,
deserializePlaylist,
} from "../model.ts";
interface WSProviderProps {
children: ReactNode;
@@ -31,12 +42,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
PlaylistEvent | null
>(null);
const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
new Set(),
);
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
null,
);
// Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts);
@@ -112,6 +127,12 @@ export function WSProvider({ children, token }: WSProviderProps) {
break;
}
case "dump_updated": {
const dump = deserializeDump(msg.dump as RawDump);
setLastDumpEvent(dump);
break;
}
case "dump_deleted": {
const dumpId = msg.dumpId as string;
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
@@ -177,6 +198,25 @@ export function WSProvider({ children, token }: WSProviderProps) {
break;
}
case "comment_created": {
const comment = deserializeComment(msg.comment as RawComment);
setLastCommentEvent({
type: "created",
dumpId: comment.dumpId,
comment,
});
break;
}
case "comment_deleted": {
const { commentId, dumpId } = msg as {
commentId: string;
dumpId: string;
};
setLastCommentEvent({ type: "deleted", dumpId, commentId });
break;
}
case "error":
// On error, revert any pending optimistic update for the affected dump
// (the revert timeout will handle it)
@@ -276,8 +316,10 @@ export function WSProvider({ children, token }: WSProviderProps) {
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
castVote,
removeVote,
};