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

@@ -69,13 +69,6 @@ export function AppHeader(
<button type="button" onClick={() => navigate("/login")}>
Log in
</button>
<button
type="button"
className="btn-primary"
onClick={() => navigate("/register")}
>
Register
</button>
</>
)}
</nav>

View File

@@ -5,8 +5,11 @@ import type { Comment, RawComment, User } from "../model.ts";
import { deserializeComment } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
import { relativeTime } from "../utils/relativeTime.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { Tooltip } from "./Tooltip.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
interface CommentThreadProps {
dumpId: string;
@@ -15,6 +18,7 @@ interface CommentThreadProps {
token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
onCommentUpdated: (comment: Comment) => void;
}
function buildTree(comments: Comment[]): Map<string, Comment[]> {
@@ -27,7 +31,7 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> {
return map;
}
const MAX_INDENT_DEPTH = 4;
const MAX_INDENT_DEPTH = 6;
interface CommentNodeProps {
comment: Comment;
@@ -38,6 +42,7 @@ interface CommentNodeProps {
token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
onCommentUpdated: (comment: Comment) => void;
}
function CommentNode({
@@ -49,12 +54,20 @@ function CommentNode({
token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: CommentNodeProps) {
const [replyOpen, setReplyOpen] = useState(false);
const [replyBody, setReplyBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [replyError, setReplyError] = useState<string | null>(null);
const replyTextareaRef = useRef<HTMLTextAreaElement>(null);
const [editOpen, setEditOpen] = useState(false);
const [editBody, setEditBody] = useState("");
const [confirmDelete, setConfirmDelete] = useState(false);
const [editSubmitting, setEditSubmitting] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null);
const children = tree.get(comment.id) ?? [];
@@ -98,8 +111,38 @@ function CommentNode({
}
}
async function handleEditSave(e: React.FormEvent) {
e.preventDefault();
if (!editBody.trim() || !token) return;
setEditSubmitting(true);
setEditError(null);
try {
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: editBody }),
});
const data = await res.json();
if (data.success) {
onCommentUpdated(deserializeComment(data.data as RawComment));
setEditOpen(false);
} else {
setEditError(data.error?.message ?? "Failed to save edit.");
}
} catch {
setEditError("Could not reach the server. Please try again.");
} finally {
setEditSubmitting(false);
}
}
const canDelete = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
const canEdit = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
if (comment.deleted) {
return (
@@ -118,7 +161,7 @@ function CommentNode({
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } : undefined}
>
{children.map((child) => (
<CommentNode
@@ -131,6 +174,7 @@ function CommentNode({
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
@@ -141,7 +185,7 @@ function CommentNode({
return (
<li className="comment-node">
<div className="comment-node-inner">
<div className="comment-node-inner" id={`comment-${comment.id}`}>
<div className="comment-avatar">
<Avatar
userId={comment.userId}
@@ -158,52 +202,121 @@ function CommentNode({
>
{comment.authorUsername}
</Link>
<time
<Link
to={`/dumps/${dumpId}#comment-${comment.id}`}
className="comment-time"
dateTime={comment.createdAt.toISOString()}
title={comment.createdAt.toLocaleString()}
>
{relativeTime(comment.createdAt)}
</time>
<Tooltip text={comment.createdAt.toLocaleString()}>
<time dateTime={comment.createdAt.toISOString()}>
{relativeTime(comment.createdAt)}
</time>
</Tooltip>
</Link>
{comment.updatedAt && (
<Tooltip text={`Edited ${comment.updatedAt.toLocaleString()}`}>
<span className="comment-edited">
edited {relativeTime(comment.updatedAt)}
</span>
</Tooltip>
)}
</div>
<Markdown className="comment-body">{comment.body}</Markdown>
{editOpen
? (
<form className="comment-form" onSubmit={handleEditSave}>
<TextEditor
ref={editEditorRef}
className="comment-reply-textarea"
value={editBody}
onChange={setEditBody}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleEditSave(e);
}}
autoResize
rows={1}
/>
{editError && (
<ErrorCard title="Failed to save edit" message={editError} />
)}
<div className="comment-form-actions">
<button
type="submit"
className="comment-submit-btn"
disabled={editSubmitting || !editBody.trim()}
>
{editSubmitting ? "Saving…" : "Save"}
</button>
<button
type="button"
className="comment-action-btn"
onClick={() => {
setEditOpen(false);
setEditBody("");
setEditError(null);
}}
>
Cancel
</button>
</div>
</form>
)
: <Markdown className="comment-body">{comment.body}</Markdown>}
<div className="comment-actions">
{currentUser && (
{currentUser && !editOpen && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setReplyOpen((v) => !v);
setTimeout(() => replyTextareaRef.current?.focus(), 0);
setTimeout(() => replyEditorRef.current?.focus(), 0);
}}
>
Reply
</button>
)}
{canDelete && (
{canEdit && !editOpen && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setEditBody(comment.body);
setEditOpen(true);
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
>
Edit
</button>
)}
{canDelete && !editOpen && (
<button
type="button"
className="comment-action-btn comment-delete-btn"
onClick={handleDelete}
onClick={() => setConfirmDelete(true)}
>
Delete
</button>
)}
{confirmDelete && (
<ConfirmModal
message="Delete this comment?"
confirmLabel="Delete"
onConfirm={() => { setConfirmDelete(false); handleDelete(); }}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
{replyOpen && (
<form className="comment-form" onSubmit={handleReply}>
<textarea
ref={replyTextareaRef}
<TextEditor
ref={replyEditorRef}
className="comment-reply-textarea"
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
onChange={setReplyBody}
onKeyDown={(e) => {
if (
e.key === "Enter" && (e.ctrlKey || e.metaKey)
) handleReply(e);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
}}
placeholder="Write a reply…"
rows={3}
autoResize
rows={1}
/>
{replyError && (
<ErrorCard title="Failed to post reply" message={replyError} />
@@ -235,7 +348,7 @@ function CommentNode({
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } : undefined}
>
{children.map((child) => (
<CommentNode
@@ -248,6 +361,7 @@ function CommentNode({
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
@@ -263,6 +377,7 @@ export function CommentThread({
token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: CommentThreadProps) {
const [topLevelBody, setTopLevelBody] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -309,33 +424,56 @@ export function CommentThread({
</h2>
{currentUser && (
<form
className="comment-form comment-top-form"
onSubmit={handleTopLevelSubmit}
>
<textarea
className="comment-reply-textarea"
value={topLevelBody}
onChange={(e) => setTopLevelBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
handleTopLevelSubmit(e);
}
}}
placeholder="Add a comment…"
rows={3}
/>
{topLevelError && (
<ErrorCard title="Failed to post comment" message={topLevelError} />
)}
<div className="comment-form-actions">
<button
type="submit"
className="comment-submit-btn"
disabled={submitting || !topLevelBody.trim()}
>
{submitting ? "Posting…" : "Post comment"}
</button>
<form className="comment-top-form" onSubmit={handleTopLevelSubmit}>
<div className="comment-top-form-inner">
<div className="comment-avatar">
<Avatar
userId={currentUser.id}
username={currentUser.username}
hasAvatar={!!currentUser.avatarMime}
size={28}
/>
</div>
<div className="comment-top-form-body">
<TextEditor
className="comment-reply-textarea"
value={topLevelBody}
onChange={setTopLevelBody}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleTopLevelSubmit(e);
}}
placeholder="Add a comment…"
autoResize
rows={1}
/>
{topLevelError && (
<ErrorCard
title="Failed to post comment"
message={topLevelError}
/>
)}
<div className="comment-form-actions">
<button
type="submit"
className="comment-submit-btn"
disabled={submitting || !topLevelBody.trim()}
>
{submitting ? "Posting…" : "Post comment"}
</button>
{topLevelBody.trim() && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setTopLevelBody("");
setTopLevelError(null);
}}
>
Cancel
</button>
)}
</div>
</div>
</div>
</form>
)}
@@ -353,6 +491,7 @@ export function CommentThread({
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>

View File

@@ -1,11 +1,13 @@
import { Link, useNavigate } from "react-router";
import type { Dump } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { dumpUrl } from "../utils/urls.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import FilePreview from "./FilePreview.tsx";
import RichContentCard from "./RichContentCard.tsx";
import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx";
interface DumpCardProps {
dump: Dump;
@@ -28,7 +30,7 @@ export function DumpCard(
function handleNavigate() {
markDumpVisited(dump.id);
navigate(`/dumps/${dump.id}`);
navigate(dumpUrl(dump));
}
return (
@@ -50,7 +52,7 @@ export function DumpCard(
<div className="dump-card-body">
<Link
to={`/dumps/${dump.id}`}
to={dumpUrl(dump)}
className="dump-card-title"
onClick={(e) => {
e.stopPropagation();
@@ -66,13 +68,14 @@ export function DumpCard(
</Markdown>
)}
<div className="dump-card-meta">
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
title={dump.createdAt.toLocaleString()}
>
{relativeTime(dump.createdAt)}
</time>
<Tooltip text={dump.createdAt.toLocaleString()}>
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
>
{relativeTime(dump.createdAt)}
</time>
</Tooltip>
{dump.commentCount > 0 && (
<span className="dump-card-comment-count">
{dump.commentCount}{" "}

View File

@@ -14,11 +14,13 @@ import { deserializeDump, deserializePlaylistMembership } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { formatBytes } from "../utils/format.ts";
import { dumpUrl } from "../utils/urls.ts";
import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
import { ErrorCard } from "./ErrorCard.tsx";
import { FileDropZone } from "./FileDropZone.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
const MAX_FILE_SIZE = 50 * 1024 * 1024;
@@ -57,15 +59,8 @@ function LocalFilePreview({ file }: { file: File }) {
if (mime.startsWith("audio/")) {
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
}
return (
<div className="local-preview-generic">
<span className="local-preview-icon">
{mime.startsWith("application/pdf") ? "📄" : "📎"}
</span>
<span className="local-preview-name">{file.name}</span>
<span className="local-preview-size">{formatBytes(file.size)}</span>
</div>
);
// For other types the drop zone chip already shows name + size.
return null;
}
interface DumpCreateModalProps {
@@ -383,17 +378,11 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
)
: (
<>
<div className="form-group">
<label htmlFor="dc-file">File</label>
<input
id="dc-file"
type="file"
onChange={(e) =>
setFile(e.target.files?.[0] ?? null)}
disabled={submitting}
required
/>
</div>
<FileDropZone
file={file}
onChange={setFile}
disabled={submitting}
/>
{file && <LocalFilePreview file={file} />}
</>
)}
@@ -459,7 +448,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
{createdDump && (
<p className="dump-create-success">
Dumped!{" "}
<Link to={`/dumps/${createdDump.id}`} onClick={onClose}>
<Link to={dumpUrl(createdDump)} onClick={onClose}>
View dump
</Link>
</p>

View File

@@ -0,0 +1,145 @@
import { useCallback, useRef, useState } from "react";
import { formatBytes } from "../utils/format.ts";
function fileIcon(mime: string): string {
if (mime.startsWith("image/")) return "🖼";
if (mime.startsWith("video/")) return "🎬";
if (mime.startsWith("audio/")) return "🎵";
if (mime === "application/pdf") return "📄";
return "📎";
}
interface FileDropZoneProps {
file: File | null;
onChange: (file: File | null) => void;
disabled?: boolean;
label?: string;
hint?: string;
showLimit?: boolean;
}
export function FileDropZone({
file,
onChange,
disabled,
label = "File",
hint = "Drop a file here",
showLimit = true,
}: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setDragging(true);
},
[disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDragging(false);
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (disabled) return;
const dropped = e.dataTransfer.files[0];
if (dropped) onChange(dropped);
},
[disabled, onChange],
);
const handleClick = useCallback(() => {
if (!disabled) inputRef.current?.click();
}, [disabled]);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange(null);
if (inputRef.current) inputRef.current.value = "";
},
[onChange],
);
return (
<div className="fdz-wrapper">
{label && <span className="fdz-label">{label}</span>}
<div
className={`fdz${dragging ? " fdz--drag" : ""}${disabled ? " fdz--disabled" : ""}${file ? " fdz--filled" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={file ? undefined : handleClick}
role={file ? undefined : "button"}
tabIndex={file || disabled ? undefined : 0}
onKeyDown={
file || disabled
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}
}
>
<input
ref={inputRef}
type="file"
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
disabled={disabled}
style={{ display: "none" }}
/>
{file
? (
<div className="fdz__file">
<span className="fdz__file-icon">{fileIcon(file.type)}</span>
<div className="fdz__file-meta">
<span className="fdz__file-name">{file.name}</span>
<span className="fdz__file-size">{formatBytes(file.size)}</span>
</div>
{!disabled && (
<button
type="button"
className="fdz__clear"
onClick={handleClear}
aria-label="Remove file"
>
</button>
)}
</div>
)
: (
<div className="fdz__empty">
<svg
className="fdz__upload-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p className="fdz__hint">{hint}</p>
<p className="fdz__browse">
or <span className="fdz__browse-link">browse files</span>
</p>
{showLimit && <p className="fdz__limit">Max 50 MB</p>}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { Link } from "react-router";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -9,6 +10,11 @@ interface MarkdownProps {
const REMARK_PLUGINS = [remarkGfm];
// Convert bare @username (not already inside a markdown link) to clickable links
function preprocessMentions(text: string): string {
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
}
export function Markdown(
{ children, className, inline = false }: MarkdownProps,
) {
@@ -21,14 +27,19 @@ export function Markdown(
<ReactMarkdown
remarkPlugins={REMARK_PLUGINS}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
a: ({ href, children: linkChildren }) => {
if (href?.startsWith("/users/")) {
return <Link to={href}>{linkChildren}</Link>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{linkChildren}
</a>
);
},
}}
>
{children}
{preprocessMentions(children)}
</ReactMarkdown>
</div>
);

View File

@@ -0,0 +1,38 @@
import { Avatar } from "./Avatar.tsx";
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
interface MentionDropdownProps {
results: UserResult[];
selectedIndex: number;
onSelect: (username: string) => void;
}
export function MentionDropdown(
{ results, selectedIndex, onSelect }: MentionDropdownProps,
) {
if (results.length === 0) return null;
return (
<ul className="mention-dropdown">
{results.map((user, i) => (
<li
key={user.id}
className={`mention-dropdown-item${
i === selectedIndex ? " mention-dropdown-item--selected" : ""
}`}
onMouseDown={(e) => {
e.preventDefault(); // keep textarea focused
onSelect(user.username);
}}
>
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={20}
/>
<span className="mention-dropdown-username">@{user.username}</span>
</li>
))}
</ul>
);
}

View File

@@ -2,11 +2,13 @@ import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { playlistUrl } from "../utils/urls.ts";
import {
isPlaylistVisited,
isRecent,
markPlaylistVisited,
} from "../utils/visited.ts";
import { Tooltip } from "./Tooltip.tsx";
interface PlaylistCardProps {
playlist: Playlist;
@@ -23,7 +25,7 @@ export function PlaylistCard(
function handleNavigate() {
markPlaylistVisited(playlist.id);
navigate(`/playlists/${playlist.id}`);
navigate(playlistUrl(playlist));
}
return (
@@ -45,7 +47,7 @@ export function PlaylistCard(
</div>
<div className="playlist-card-body">
<Link
to={`/playlists/${playlist.id}`}
to={playlistUrl(playlist)}
className="playlist-card-title"
onClick={(e) => {
e.stopPropagation();
@@ -81,12 +83,11 @@ export function PlaylistCard(
{playlist.dumpCount === 1 ? "dump" : "dumps"}
</span>
)}
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
>
{relativeTime(playlist.createdAt)}
</time>
<Tooltip text={playlist.createdAt.toLocaleString()}>
<time dateTime={playlist.createdAt.toISOString()}>
{relativeTime(playlist.createdAt)}
</time>
</Tooltip>
</div>
</div>
</div>

View File

@@ -0,0 +1,90 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
export interface TextEditorHandle {
focus(): void;
}
interface TextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
rows?: number;
id?: string;
className?: string;
autoResize?: boolean;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
}
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
function TextEditor(
{
value,
onChange,
placeholder,
disabled,
rows,
id,
className,
autoResize = false,
onKeyDown,
},
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => ({
focus: () => textareaRef.current?.focus(),
}), []);
const {
mentionOpen,
mentionResults,
mentionSelectedIndex,
handleMentionChange,
handleMentionKeyDown,
handleMentionSelect,
} = useMentionAutocomplete(value, onChange, textareaRef);
useEffect(() => {
if (!autoResize) return;
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value, autoResize]);
return (
<div className="mention-textarea-wrap">
<textarea
ref={textareaRef}
value={value}
onChange={handleMentionChange}
onKeyDown={(e) => {
handleMentionKeyDown(e);
if (!e.defaultPrevented) onKeyDown?.(e);
}}
placeholder={placeholder}
disabled={disabled}
rows={rows}
id={id}
className={className}
/>
{mentionOpen && (
<MentionDropdown
results={mentionResults}
selectedIndex={mentionSelectedIndex}
onSelect={handleMentionSelect}
/>
)}
</div>
);
},
);

View File

@@ -0,0 +1,39 @@
import { useCallback, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
text: string;
children: React.ReactNode;
}
export function Tooltip({ text, children }: TooltipProps) {
const [rect, setRect] = useState<DOMRect | null>(null);
const ref = useRef<HTMLSpanElement>(null);
const show = useCallback(() => {
setRect(ref.current?.getBoundingClientRect() ?? null);
}, []);
const hide = useCallback(() => setRect(null), []);
return (
<span ref={ref} onMouseEnter={show} onMouseLeave={hide}>
{children}
{rect &&
createPortal(
<div
className="tooltip"
style={{
position: "fixed",
left: rect.left + rect.width / 2,
top: rect.top,
transform: "translate(-50%, calc(-100% - 7px))",
}}
>
{text}
</div>,
document.body,
)}
</span>
);
}