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,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,
};
}