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

@@ -0,0 +1,49 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Dump } from "../model.ts";
import { useWS } from "./useWS.ts";
/**
* Keeps a dump list in sync with real-time WS events:
* - deletedDumpIds: filters out dumps that were deleted or privatised.
* - lastDumpEvent: updates existing dumps in-place; optionally prepends
* new ones when `addFilter` returns true for them.
*
* @param setDumps Updater that patches the caller's dump array.
* @param addFilter Optional predicate: return true to prepend a dump that
* isn't already in the list (e.g. became public).
*/
export function useDumpListSync(
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
addFilter?: (dump: Dump) => boolean,
): void {
const { deletedDumpIds, lastDumpEvent } = useWS();
// Keep refs up-to-date so closures in effects are never stale.
const setDumpsRef = useRef(setDumps);
const addFilterRef = useRef(addFilter);
useLayoutEffect(() => {
setDumpsRef.current = setDumps;
addFilterRef.current = addFilter;
});
useEffect(() => {
if (deletedDumpIds.size === 0) return;
setDumpsRef.current((prev) => prev.filter((d) => !deletedDumpIds.has(d.id)));
}, [deletedDumpIds]);
useEffect(() => {
if (!lastDumpEvent) return;
setDumpsRef.current((prev) => {
const idx = prev.findIndex((d) => d.id === lastDumpEvent.id);
if (idx !== -1) {
const next = [...prev];
next[idx] = lastDumpEvent;
return next;
}
if (addFilterRef.current?.(lastDumpEvent)) {
return [lastDumpEvent, ...prev];
}
return prev;
});
}, [lastDumpEvent]);
}

View File

@@ -0,0 +1,162 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { API_URL } from "../config/api.ts";
export interface UserResult {
id: string;
username: string;
avatarMime: string | null;
}
interface MentionState {
open: boolean;
query: string;
start: number;
results: UserResult[];
selectedIndex: number;
}
const CLOSED: MentionState = {
open: false,
query: "",
start: 0,
results: [],
selectedIndex: 0,
};
function getMentionQuery(
value: string,
pos: number,
): { query: string; start: number } | null {
const textBefore = value.slice(0, pos);
// Match @word at end of text before cursor, not preceded by [ ( or word char
const match = textBefore.match(/(?<![[(A-Za-z0-9_])@([A-Za-z0-9_]*)$/);
if (!match || match[1].length === 0) return null;
const start = pos - match[0].length;
return { query: match[1], start };
}
export function useMentionAutocomplete(
value: string,
onChange: (v: string) => void,
textareaRef: RefObject<HTMLTextAreaElement | null>,
) {
const [state, setState] = useState<MentionState>(CLOSED);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cursorRef = useRef<number>(0);
const handleMentionChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const pos = e.target.selectionStart ?? newValue.length;
cursorRef.current = pos;
onChange(newValue);
const mention = getMentionQuery(newValue, pos);
if (!mention) {
setState(CLOSED);
if (debounceRef.current) clearTimeout(debounceRef.current);
return;
}
setState((s) => ({
...s,
open: false,
query: mention.query,
start: mention.start,
selectedIndex: 0,
}));
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`${API_URL}/api/users/search?q=${encodeURIComponent(mention.query)}`,
);
const body = await res.json();
if (body.success && body.data.length > 0) {
setState((s) =>
s.query === mention.query
? { ...s, open: true, results: body.data, selectedIndex: 0 }
: s
);
} else {
setState(CLOSED);
}
} catch {
setState(CLOSED);
}
}, 150);
},
[onChange],
);
const doSelect = useCallback(
(username: string, start: number, cursorPos: number) => {
const before = value.slice(0, start);
const after = value.slice(cursorPos);
onChange(`${before}@${username} ${after}`);
setState(CLOSED);
if (debounceRef.current) clearTimeout(debounceRef.current);
setTimeout(() => {
const el = textareaRef.current;
if (el) {
const newPos = start + username.length + 2; // @ + username + space
el.focus();
el.setSelectionRange(newPos, newPos);
}
}, 0);
},
[value, onChange, textareaRef],
);
const handleMentionKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!state.open || state.results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setState((s) => ({
...s,
selectedIndex: Math.min(s.selectedIndex + 1, s.results.length - 1),
}));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setState((s) => ({
...s,
selectedIndex: Math.max(s.selectedIndex - 1, 0),
}));
} else if (
(e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey
) {
e.preventDefault();
const user = state.results[state.selectedIndex];
if (user) doSelect(user.username, state.start, cursorRef.current);
} else if (e.key === "Escape") {
e.preventDefault();
setState(CLOSED);
}
},
[state, doSelect],
);
const handleMentionSelect = useCallback(
(username: string) => {
doSelect(username, state.start, cursorRef.current);
},
[doSelect, state.start],
);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return {
mentionOpen: state.open,
mentionResults: state.results,
mentionSelectedIndex: state.selectedIndex,
handleMentionChange,
handleMentionKeyDown,
handleMentionSelect,
};
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Playlist } from "../model.ts";
import { useWS } from "./useWS.ts";
interface PlaylistListSyncOptions {
/** Keep private playlists visible (caller is the owner). */
isOwner?: boolean;
/**
* Only act on created/updated events for playlists owned by this user.
* Leave undefined to act on any playlist already in the list.
*/
ownerId?: string;
/**
* Set true for "followed" lists: never prepend newly created playlists
* (followed membership is managed separately), but still update/remove.
*/
noNewEntries?: boolean;
}
/**
* Keeps a playlist list in sync with real-time WS events:
* - lastPlaylistEvent "created" → prepend (unless noNewEntries)
* - lastPlaylistEvent "updated" → update in-place, or remove if now private
* - lastPlaylistEvent "deleted" → remove
* - deletedPlaylistIds growing → filter
*/
export function usePlaylistListSync(
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
options?: PlaylistListSyncOptions,
): void {
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
const setPlaylistsRef = useRef(setPlaylists);
const optionsRef = useRef(options);
useLayoutEffect(() => {
setPlaylistsRef.current = setPlaylists;
optionsRef.current = options;
});
useEffect(() => {
if (!lastPlaylistEvent) return;
const { isOwner, ownerId, noNewEntries } = optionsRef.current ?? {};
const ev = lastPlaylistEvent;
setPlaylistsRef.current((prev) => {
if (ev.type === "created" && ev.playlist) {
if (noNewEntries) return prev;
if (ownerId && ev.playlist.userId !== ownerId) return prev;
if (!ev.playlist.isPublic && !isOwner) return prev;
if (prev.some((p) => p.id === ev.playlist!.id)) return prev;
return [ev.playlist, ...prev];
}
if (ev.type === "updated" && ev.playlist) {
const idx = prev.findIndex((p) => p.id === ev.playlist!.id);
if (idx === -1) {
// Playlist was removed (went private). Re-add if it's now public and
// visible to this list. noNewEntries lists (followed) skip this since
// we can't verify the follow relationship is still active.
if (noNewEntries) return prev;
if (ownerId && ev.playlist.userId !== ownerId) return prev;
if (!ev.playlist.isPublic && !isOwner) return prev;
return [ev.playlist, ...prev];
}
// Remove if it became private and the viewer can't see private playlists
if (!ev.playlist.isPublic && !isOwner) {
return prev.filter((p) => p.id !== ev.playlist!.id);
}
const next = [...prev];
next[idx] = ev.playlist;
return next;
}
if (ev.type === "deleted") {
return prev.filter((p) => p.id !== ev.playlistId);
}
return prev;
});
}, [lastPlaylistEvent]);
useEffect(() => {
if (deletedPlaylistIds.size === 0) return;
setPlaylistsRef.current((prev) =>
prev.filter((p) => !deletedPlaylistIds.has(p.id))
);
}, [deletedPlaylistIds]);
}