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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}{" "}
|
||||
|
||||
@@ -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>
|
||||
|
||||
145
src/components/FileDropZone.tsx
Normal file
145
src/components/FileDropZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
38
src/components/MentionDropdown.tsx
Normal file
38
src/components/MentionDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
90
src/components/TextEditor.tsx
Normal file
90
src/components/TextEditor.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
39
src/components/Tooltip.tsx
Normal file
39
src/components/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user