v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone

This commit is contained in:
khannurien
2026-03-22 16:06:26 +00:00
parent 39a0cc397e
commit 34e908d1bc
42 changed files with 2170 additions and 628 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
@@ -19,6 +19,8 @@ import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
@@ -114,10 +116,9 @@ export function UserPublicProfile() {
voteCounts,
myVotes,
lastVoteEvent,
lastDumpEvent,
castVote,
removeVote,
lastPlaylistEvent,
deletedPlaylistIds,
} = useWS();
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
@@ -136,11 +137,104 @@ export function UserPublicProfile() {
);
const [state, setState] = useState<ProfileState>({ status: "loading" });
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const profileUserId = state.status === "loaded" ? state.user.id : null;
const isOwnProfile = me?.id === profileUserId;
const removedDumpPositionsRef = useRef<Map<string, number>>(new Map());
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => {
if (s.status !== "loaded") return s;
const prev = s.dumps.items;
const next = fn(prev);
if (next.length < prev.length) {
const nextIds = new Set(next.map((d) => d.id));
prev.forEach((d, idx) => {
if (!nextIds.has(d.id)) removedDumpPositionsRef.current.set(d.id, idx);
});
}
return { ...s, dumps: { ...s.dumps, items: next } };
});
}, []);
// No addFilter — insertion at correct position is handled by the effect below.
useDumpListSync(setDumps);
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
new Set(),
);
// Tracks the list index of each dump at the moment it was removed from the
// votes list, so we can re-insert it at the correct position when it becomes
// public again (instead of always prepending at position 0).
const removedVotePositionsRef = useRef<Map<string, number>>(new Map());
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
// a future dump_updated event (that would only be for private→public transitions).
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => {
if (s.status !== "loaded") return s;
const prev = s.votes.items;
const next = fn(prev);
if (next.length < prev.length) {
const nextIds = new Set(next.map((d) => d.id));
prev.forEach((d, idx) => {
if (!nextIds.has(d.id)) removedVotePositionsRef.current.set(d.id, idx);
});
}
return { ...s, votes: { ...s.votes, items: next } };
});
}, []);
useDumpListSync(setVotes);
// Re-insert a vote-list dump at its original position after private→public.
// Skip dumps whose vote was explicitly withdrawn (those were removed intentionally).
useEffect(() => {
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
const dump = lastDumpEvent;
if (withdrawnVoteIdsRef.current.has(dump.id)) return;
const savedIdx = removedVotePositionsRef.current.get(dump.id);
if (savedIdx === undefined) return;
removedVotePositionsRef.current.delete(dump.id);
setVotes((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev;
const next = [...prev];
next.splice(Math.min(savedIdx, next.length), 0, dump);
return next;
});
}, [lastDumpEvent, setVotes]);
// Re-insert a dumps-column dump at its original position after private→public.
useEffect(() => {
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
const dump = lastDumpEvent;
if (dump.userId !== profileUserId) return;
const savedIdx = removedDumpPositionsRef.current.get(dump.id);
if (savedIdx === undefined) return;
removedDumpPositionsRef.current.delete(dump.id);
setDumps((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev;
const next = [...prev];
next.splice(Math.min(savedIdx, next.length), 0, dump);
return next;
});
}, [lastDumpEvent, profileUserId, setDumps]);
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
s.status !== "loaded"
? s
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
);
}, []);
usePlaylistListSync(setPlaylists, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
});
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
@@ -260,8 +354,6 @@ export function UserPublicProfile() {
})();
}, [username]);
const profileUserId = state.status === "loaded" ? state.user.id : null;
// Own profile: keep profileVotedIds in sync with myVotes
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
@@ -301,7 +393,10 @@ export function UserPublicProfile() {
return n;
});
}
withdrawnVoteIdsRef.current.add(dumpId);
setVotes((prev) => prev.filter((d) => d.id !== dumpId));
} else {
withdrawnVoteIdsRef.current.delete(dumpId);
if (!isOwnProfile) {
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
}
@@ -327,65 +422,6 @@ export function UserPublicProfile() {
}
}, [lastVoteEvent, me, profileUserId]);
// Real-time playlist updates
useEffect(() => {
if (!lastPlaylistEvent || state.status !== "loaded") return;
const profileUserId = state.user.id;
const isOwnProfile = me?.id === profileUserId;
const ev = lastPlaylistEvent;
if (ev.type === "created" && ev.playlist?.userId === profileUserId) {
if (ev.playlist.isPublic || isOwnProfile) {
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
return {
...s,
playlists: {
...s.playlists,
items: [ev.playlist!, ...s.playlists.items],
},
};
});
}
} else if (ev.type === "updated" && ev.playlist?.userId === profileUserId) {
setState((s) => {
if (s.status !== "loaded") return s;
return {
...s,
playlists: {
...s.playlists,
items: s.playlists.items
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
.filter((p) => p.isPublic || isOwnProfile),
},
};
});
} else if (ev.type === "deleted") {
setState((s) => {
if (s.status !== "loaded") return s;
return {
...s,
playlists: {
...s.playlists,
items: s.playlists.items.filter((p) => p.id !== ev.playlistId),
},
};
});
}
}, [lastPlaylistEvent, me]);
useEffect(() => {
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
setState((s) => {
if (s.status !== "loaded") return s;
const filtered = s.playlists.items.filter((p) =>
!deletedPlaylistIds.has(p.id)
);
if (filtered.length === s.playlists.items.length) return s;
return { ...s, playlists: { ...s.playlists, items: filtered } };
});
}, [deletedPlaylistIds]);
// Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => {
@@ -506,7 +542,6 @@ export function UserPublicProfile() {
}
const { user: profileUser, dumps, votes, playlists } = state;
const isOwnProfile = me?.username === profileUser.username;
return (
<PageShell>