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,6 +1,6 @@
/* ── Markdown prose ── */
.md p {
margin: 0 0 0.7em;
margin: 0 0 0.85em;
}
.md p:last-child {
margin-bottom: 0;
@@ -120,7 +120,7 @@
.dump-comment {
font-size: 1.05rem;
line-height: 1.6;
line-height: 1.72;
opacity: 0.85;
border-left: 3px solid var(--color-accent);
margin: 0;
@@ -318,6 +318,138 @@
opacity: 0.7;
}
/* ── FileDropZone ── */
.fdz-wrapper {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.fdz-label {
font-size: 0.9rem;
font-weight: 500;
}
.fdz {
border: 2px dashed var(--color-border-subtle);
border-radius: 10px;
background: var(--color-surface);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
outline: none;
}
.fdz:hover:not(.fdz--disabled):not(.fdz--filled),
.fdz:focus-visible {
border-color: var(--color-accent);
}
.fdz--drag {
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface));
}
.fdz--disabled {
cursor: not-allowed;
opacity: 0.55;
}
.fdz--filled {
cursor: default;
border-style: solid;
}
.fdz__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 2rem 1.5rem;
user-select: none;
}
.fdz__upload-icon {
width: 2rem;
height: 2rem;
opacity: 0.4;
margin-bottom: 0.2rem;
}
.fdz__hint {
margin: 0;
font-size: 0.9rem;
font-weight: 500;
}
.fdz__browse {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.fdz__browse-link {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.fdz__limit {
margin: 0.25rem 0 0;
font-size: 0.78rem;
color: var(--color-text-muted);
}
.fdz__file {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
}
.fdz__file-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.fdz__file-meta {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
flex: 1;
}
.fdz__file-name {
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fdz__file-size {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.fdz__clear {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.8rem;
padding: 0.3rem 0.5rem;
border-radius: 4px;
transition: color 0.12s, background 0.12s;
line-height: 1;
}
.fdz__clear:hover {
color: var(--color-text);
background: var(--color-border-subtle);
}
/* ── Local file / URL preview (DumpCreate) ── */
.local-preview-image {
width: 100%;
@@ -559,7 +691,8 @@
.file-preview-pdf {
width: 100%;
min-height: 420px;
height: 80vh;
min-height: 600px;
border-radius: 8px;
border: 2px solid var(--color-border);
display: block;
@@ -1907,16 +2040,22 @@ body.has-player .fab-new {
.dump-card-meta {
display: flex;
align-items: center;
align-items: baseline;
gap: 0.5rem;
margin-top: 0.2rem;
}
.dump-card-date {
display: block;
margin-top: 0;
}
.dump-edited-label,
.playlist-edited-label {
font-size: 0.72rem;
opacity: 0.5;
font-style: italic;
}
.playlist-card-meta {
display: flex;
align-items: center;
@@ -2213,9 +2352,10 @@ body.has-player .fab-new {
}
.playlist-detail-description {
font-size: 1rem;
margin: 0 0 0.5rem;
opacity: 0.75;
line-height: 1.5;
line-height: 1.75;
}
.playlist-detail-meta {
@@ -2518,11 +2658,32 @@ body.has-player .fab-new {
.comment-time {
font-size: 0.75rem;
color: var(--color-text-muted);
text-decoration: none;
}
.comment-time:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
@keyframes comment-highlight {
0% { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
100% { background: transparent; }
}
.comment-node--highlight {
border-radius: 6px;
animation: comment-highlight 2s ease-out forwards;
}
.comment-edited {
font-size: 0.72rem;
color: var(--color-text-muted);
opacity: 0.7;
font-style: italic;
}
.comment-body {
font-size: 0.9rem;
line-height: 1.6;
line-height: 1.65;
color: var(--color-text);
}
@@ -2582,6 +2743,20 @@ body.has-player .fab-new {
border: 1px solid var(--color-border-subtle);
}
.comment-top-form-inner {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.comment-top-form-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.comment-reply-textarea {
width: 100%;
box-sizing: border-box;
@@ -2592,8 +2767,9 @@ body.has-player .fab-new {
font-family: inherit;
font-size: 0.875rem;
color: var(--color-text);
resize: vertical;
min-height: 4.5rem;
resize: none;
overflow: hidden;
min-height: 2.4rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
@@ -2866,15 +3042,12 @@ body.has-player .fab-new {
gap: 1rem;
}
.notification-item {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1rem;
background: var(--color-surface);
border-radius: 10px;
border: 1px solid var(--color-border-subtle);
border-left: 3px solid transparent;
transition: background 0.12s, border-color 0.12s;
overflow: hidden;
}
.notification-item:hover {
background: color-mix(
@@ -2883,6 +3056,15 @@ body.has-player .fab-new {
var(--color-text) 8%
);
}
.notification-item-link {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1rem;
text-decoration: none;
color: inherit;
width: 100%;
}
.notification-item--unread {
border-left-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface));
@@ -2945,15 +3127,6 @@ body.has-player .fab-new {
white-space: nowrap;
flex-shrink: 0;
}
.notif-link {
color: var(--color-text);
text-decoration: none;
font-weight: 600;
}
.notif-link:hover {
color: var(--color-accent);
text-decoration: underline;
}
.load-more-btn {
display: block;
margin: 1.5rem auto 0;
@@ -2974,3 +3147,64 @@ body.has-player .fab-new {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Mention autocomplete ── */
.mention-textarea-wrap {
position: relative;
}
.mention-textarea-wrap textarea {
width: 100%;
box-sizing: border-box;
}
.mention-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 200;
list-style: none;
margin: 2px 0 0;
padding: 4px 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
max-height: 220px;
overflow-y: auto;
}
.mention-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
transition: background 0.1s;
}
.mention-dropdown-item:hover,
.mention-dropdown-item--selected {
background: var(--color-bg);
}
.mention-dropdown-username {
font-size: 0.9rem;
color: var(--color-text);
}
.notif-icon--mention {
font-weight: 700;
font-family: monospace;
}
/* ── Tooltip ── */
.tooltip {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-subtle);
padding: 0.3em 0.65em;
border-radius: 5px;
font-size: 0.78rem;
font-style: normal;
white-space: nowrap;
pointer-events: none;
z-index: 9999;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}

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

View File

@@ -22,7 +22,7 @@ export interface PlaylistEvent {
}
export interface CommentEvent {
type: "created" | "deleted";
type: "created" | "deleted" | "updated";
dumpId: string;
comment?: Comment;
commentId?: string;

View File

@@ -140,6 +140,18 @@ export function WSProvider({ children, token }: WSProviderProps) {
case "dump_updated": {
const dump = deserializeDump(msg.dump as RawDump);
setLastDumpEvent(dump);
// Un-delete if this dump was previously removed from the feed
// (e.g. it was made private, and is now public again).
setDeletedDumpIds((prev) => {
if (!prev.has(dump.id)) return prev;
const next = new Set(prev);
next.delete(dump.id);
return next;
});
// Add to live feed if not already present (private→public).
setRecentDumps((prev) =>
prev.some((d) => d.id === dump.id) ? prev : [dump, ...prev]
);
break;
}
@@ -227,6 +239,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
break;
}
case "comment_updated": {
const comment = deserializeComment(msg.comment as RawComment);
setLastCommentEvent({
type: "updated",
dumpId: comment.dumpId,
comment,
});
break;
}
case "notification_created": {
const notification = deserializeNotification(
msg.notification as RawNotification,

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]);
}

View File

@@ -23,9 +23,11 @@ export interface Dump {
id: string;
kind: "url" | "file";
title: string;
slug?: string;
comment?: string;
userId: string;
createdAt: Date;
updatedAt?: Date;
url?: string;
richContent?: RichContent;
fileName?: string;
@@ -45,6 +47,7 @@ export interface User {
username: string;
isAdmin: boolean;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
invitedByUsername?: string;
}
@@ -55,14 +58,15 @@ export interface PublicUser {
username: string;
isAdmin: boolean;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
invitedByUsername?: string;
}
// Wire types — createdAt arrives as an ISO string from API/WS/localStorage
type WithStringDate<T extends { createdAt: Date }> = Omit<T, "createdAt"> & {
createdAt: string;
};
// Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage
type WithStringDate<T extends { createdAt: Date }> =
& Omit<T, "createdAt" | "updatedAt">
& { createdAt: string; updatedAt?: string };
export type RawDump = WithStringDate<Dump>;
export type RawUser = WithStringDate<User>;
export type RawPublicUser = WithStringDate<PublicUser>;
@@ -70,15 +74,27 @@ export type RawAuthResponse = Omit<AuthResponse, "user"> & { user: RawUser };
// Deserializers — convert wire types to domain types at API/WS/localStorage boundaries
export function deserializeDump(raw: RawDump): Dump {
return { ...raw, createdAt: new Date(raw.createdAt) };
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
};
}
export function deserializeUser(raw: RawUser): User {
return { ...raw, createdAt: new Date(raw.createdAt) };
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
};
}
export function deserializePublicUser(raw: RawPublicUser): PublicUser {
return { ...raw, createdAt: new Date(raw.createdAt) };
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
};
}
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
@@ -117,6 +133,7 @@ export interface Comment {
parentId?: string;
body: string;
createdAt: Date;
updatedAt?: Date;
deleted: boolean;
authorUsername: string;
authorAvatarMime?: string;
@@ -125,7 +142,11 @@ export interface Comment {
export type RawComment = WithStringDate<Comment>;
export function deserializeComment(raw: RawComment): Comment {
return { ...raw, createdAt: new Date(raw.createdAt) };
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
};
}
/**
@@ -136,9 +157,11 @@ export interface Playlist {
id: string;
userId: string;
title: string;
slug?: string;
description?: string;
isPublic: boolean;
createdAt: Date;
updatedAt?: Date;
imageMime?: string;
dumpCount?: number;
ownerUsername?: string;
@@ -163,7 +186,11 @@ export type RawPlaylistWithDumps =
export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean };
export function deserializePlaylist(raw: RawPlaylist): Playlist {
return { ...raw, createdAt: new Date(raw.createdAt) };
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
};
}
export function deserializePlaylistWithDumps(
@@ -172,6 +199,7 @@ export function deserializePlaylistWithDumps(
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
dumps: raw.dumps.map(deserializeDump),
};
}
@@ -340,7 +368,8 @@ export type NotificationType =
| "user_followed"
| "user_dump_posted"
| "playlist_dump_added"
| "dump_upvoted";
| "dump_upvoted"
| "user_mentioned";
export interface PlaylistFollowedData {
followerId: string;
@@ -375,12 +404,22 @@ export interface DumpUpvotedData {
dumpTitle: string;
}
export interface UserMentionedData {
mentionerId: string;
mentionerUsername: string;
contextType: "comment" | "dump" | "playlist";
contextId: string;
contextTitle: string;
dumpId?: string;
}
export type NotificationData =
| PlaylistFollowedData
| UserFollowedData
| UserDumpPostedData
| PlaylistDumpAddedData
| DumpUpvotedData;
| DumpUpvotedData
| UserMentionedData;
export interface Notification {
id: string;

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import { dumpUrl } from "../utils/urls.ts";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
import { API_URL } from "../config/api.ts";
@@ -22,6 +23,7 @@ import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { CommentThread } from "../components/CommentThread.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type DumpState =
@@ -115,6 +117,18 @@ export function Dump() {
.catch(() => {});
}, [selectedDump, token]);
// Scroll to and highlight a comment when navigating to #comment-{id}
useEffect(() => {
if (!location.hash.startsWith("#comment-")) return;
const id = location.hash.slice(1);
const el = document.getElementById(id);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.add("comment-node--highlight");
const t = setTimeout(() => el.classList.remove("comment-node--highlight"), 2000);
return () => clearTimeout(t);
}, [comments, location.hash]);
// React to WS comment events
useEffect(() => {
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
@@ -135,6 +149,14 @@ export function Dump() {
: c
)
);
} else if (
lastCommentEvent.type === "updated" && lastCommentEvent.comment
) {
setComments((prev) =>
prev.map((c) =>
c.id === lastCommentEvent.comment!.id ? lastCommentEvent.comment! : c
)
);
}
}, [lastCommentEvent, selectedDump]);
@@ -213,13 +235,21 @@ export function Dump() {
</Link>
)
: <span className="dump-op-link"></span>}
<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.updatedAt && (
<Tooltip text={`Edited ${dump.updatedAt.toLocaleString()}`}>
<span className="dump-edited-label">
edited {relativeTime(dump.updatedAt)}
</span>
</Tooltip>
)}
{dump.isPrivate && (
<span className="dump-card-private-badge">private</span>
)}
@@ -251,7 +281,7 @@ export function Dump() {
{/* Actions */}
<div className="dump-actions">
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
{canEdit && <Link to={`${dumpUrl(dump)}/edit`}>Edit</Link>}
<Link to="/"> Back to all dumps</Link>
</div>
@@ -271,6 +301,10 @@ export function Dump() {
c.id === id ? { ...c, deleted: true, body: "" } : c
)
)}
onCommentUpdated={(updated) =>
setComments((prev) =>
prev.map((c) => (c.id === updated.id ? updated : c))
)}
/>
</div>
{playlistModalOpen && (

View File

@@ -6,10 +6,13 @@ import { API_URL } from "../config/api.ts";
import type { CreateUrlDumpRequest, RichContent } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts";
import { dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
import { MediaPlayer } from "../components/MediaPlayer.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
const MAX_FILE_SIZE = 50 * 1024 * 1024;
@@ -46,15 +49,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;
}
export function DumpCreate() {
@@ -144,7 +140,7 @@ export function DumpCreate() {
const apiResponse = await res.json();
if (apiResponse.success) {
navigate(`/dumps/${apiResponse.data.id}`);
navigate(dumpUrl(apiResponse.data));
} else {
setState({
status: "error",
@@ -266,26 +262,21 @@ export function DumpCreate() {
)
: (
<>
<div key="file-field" className="form-group">
<label htmlFor="file">File</label>
<input
id="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} />}
</>
)}
<div className="form-group">
<label htmlFor="comment">Why are you dumping this?</label>
<textarea
<TextEditor
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
onChange={setComment}
disabled={submitting}
placeholder="Tell the community what makes this worth their time..."
rows={3}

View File

@@ -6,12 +6,15 @@ import type { Dump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts";
import { dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx";
type DumpEditState =
| { status: "loading" }
@@ -101,7 +104,7 @@ export function DumpEdit() {
const updatedDump: Dump = deserializeDump(apiResponse.data);
setState({ status: "loaded", dump: updatedDump });
setNewFile(null);
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
navigate(dumpUrl(updatedDump), { state: { dump: updatedDump } });
};
const handleRefreshMetadata = async () => {
@@ -236,26 +239,22 @@ export function DumpEdit() {
<strong>{dump.fileName}</strong>
{dump.fileSize != null && `${formatBytes(dump.fileSize)}`}
</p>
<label htmlFor="replace-file">Replace file</label>
<input
id="replace-file"
type="file"
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
<FileDropZone
file={newFile}
onChange={setNewFile}
label="Replace file"
hint="Drop a replacement here"
showLimit={false}
/>
{newFile && (
<p className="file-input-info">
{newFile.name} {formatBytes(newFile.size)}
</p>
)}
</div>
)}
<div className="form-group">
<label htmlFor="comment">Why are you dumping this?</label>
<textarea
<TextEditor
id="comment"
value={comment}
onChange={(e) => setComment(e.currentTarget.value)}
onChange={setComment}
placeholder="Tell the community what makes this worth their time..."
rows={3}
/>
@@ -287,7 +286,7 @@ export function DumpEdit() {
Delete dump
</button>
<div className="form-actions-right">
<Link to={`/dumps/${dump.id}`} className="form-cancel">
<Link to={dumpUrl(dump)} className="form-cancel">
Cancel
</Link>
<button type="submit" className="btn-primary">Save</button>

View File

@@ -26,6 +26,7 @@ import { friendlyFetchError } from "../utils/apiError.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
const PAGE_SIZE = 20;
@@ -172,6 +173,24 @@ export function Index() {
DumpsState
>({ status: "loading" });
const setFollowedUsersDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setFollowedUsersDumps((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setFollowedUsersDumpsItems);
const setFollowedPlaylistsDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setFollowedPlaylistsDumps((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setFollowedPlaylistsDumpsItems);
const [tab, setTab] = useState<FeedTab>("hot");
const [followedSection, setFollowedSection] = useState<FollowedSection>(
"users",

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { useWS } from "../hooks/useWS.ts";
import type {
DumpUpvotedData,
@@ -15,6 +16,7 @@ import type {
RawNotification,
UserDumpPostedData,
UserFollowedData,
UserMentionedData,
} from "../model.ts";
import { deserializeNotification } from "../model.ts";
import { PageShell } from "../components/PageShell.tsx";
@@ -33,7 +35,7 @@ type State =
loadingMore: boolean;
};
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist";
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist" | "mention";
function notifIconKind(type: Notification["type"]): NotifIconKind {
switch (type) {
@@ -47,16 +49,31 @@ function notifIconKind(type: Notification["type"]): NotifIconKind {
return "dump";
case "playlist_dump_added":
return "playlist";
case "user_mentioned":
return "mention";
}
}
const UpvoteSvg = () => (
<svg viewBox="0 0 10 10" width="11" height="11" fill="currentColor">
<polygon points="5,1 9,9 1,9" />
</svg>
);
const FollowSvg = () => (
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
<polygon points="2,1 9,5 2,9" />
</svg>
);
function NotifIcon({ type }: { type: Notification["type"] }) {
const kind = notifIconKind(type);
const glyphs: Record<NotifIconKind, string> = {
upvote: "▲",
follow: "►",
const glyphs: Record<NotifIconKind, React.ReactNode> = {
upvote: <UpvoteSvg />,
follow: <FollowSvg />,
dump: "🚚",
playlist: "📜",
mention: "@",
};
return (
<span className={`notif-icon notif-icon--${kind}`}>
@@ -65,78 +82,56 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
);
}
function notificationLink(n: Notification): string {
const data = n.data as NotificationData;
switch (n.type) {
case "user_followed":
return `/users/${(data as UserFollowedData).followerUsername}`;
case "playlist_followed":
return `/playlists/${(data as PlaylistFollowedData).playlistId}`;
case "user_dump_posted":
return `/dumps/${(data as UserDumpPostedData).dumpId}`;
case "playlist_dump_added":
return `/dumps/${(data as PlaylistDumpAddedData).dumpId}`;
case "dump_upvoted":
return `/dumps/${(data as DumpUpvotedData).dumpId}`;
case "user_mentioned": {
const d = data as UserMentionedData;
if (d.contextType === "comment") return `/dumps/${d.dumpId}#comment-${d.contextId}`;
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
return `/playlists/${d.contextId}`;
}
}
}
function notificationContent(n: Notification): React.ReactNode {
const data = n.data as NotificationData;
switch (n.type) {
case "user_followed": {
const d = data as UserFollowedData;
return (
<>
<Link to={`/users/${d.followerUsername}`} className="notif-link">
{d.followerUsername}
</Link>
{" started following you"}
</>
);
return <><strong>{d.followerUsername}</strong>{" started following you"}</>;
}
case "playlist_followed": {
const d = data as PlaylistFollowedData;
return (
<>
<Link to={`/users/${d.followerUsername}`} className="notif-link">
{d.followerUsername}
</Link>
{" followed your playlist "}
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
{d.playlistTitle}
</Link>
</>
);
return <><strong>{d.followerUsername}</strong>{" followed your playlist "}<strong>{d.playlistTitle}</strong></>;
}
case "user_dump_posted": {
const d = data as UserDumpPostedData;
return (
<>
<Link to={`/users/${d.dumperUsername}`} className="notif-link">
{d.dumperUsername}
</Link>
{" posted "}
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
</>
);
return <><strong>{d.dumperUsername}</strong>{" posted "}<strong>{d.dumpTitle}</strong></>;
}
case "playlist_dump_added": {
const d = data as PlaylistDumpAddedData;
return (
<>
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
{" was added to "}
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
{d.playlistTitle}
</Link>
</>
);
return <><strong>{d.dumpTitle}</strong>{" was added to "}<strong>{d.playlistTitle}</strong></>;
}
case "dump_upvoted": {
const d = data as DumpUpvotedData;
return (
<>
<Link to={`/users/${d.voterUsername}`} className="notif-link">
{d.voterUsername}
</Link>
{" upvoted "}
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
</>
);
return <><strong>{d.voterUsername}</strong>{" upvoted "}<strong>{d.dumpTitle}</strong></>;
}
case "user_mentioned": {
const d = data as UserMentionedData;
const where = d.contextTitle || (d.contextType === "comment" ? "a comment" : "a post");
return <><strong>{d.mentionerUsername}</strong>{" mentioned you in "}<strong>{where}</strong></>;
}
default:
return "New notification";
}
}
@@ -308,21 +303,28 @@ export function Notifications() {
!n.read ? " notification-item--unread" : ""
}`}
>
<NotifIcon type={n.type} />
<div className="notification-body">
<span className="notification-content">
{notificationContent(n)}
</span>
<time
className="notification-time"
dateTime={n.createdAt.toISOString()}
>
{timeAgo(n.createdAt)}
</time>
</div>
{!n.read && (
<span className="notif-dot" aria-hidden="true" />
)}
<Link
to={notificationLink(n)}
className="notification-item-link"
>
<NotifIcon type={n.type} />
<div className="notification-body">
<span className="notification-content">
{notificationContent(n)}
</span>
<Tooltip text={n.createdAt.toLocaleString()}>
<time
className="notification-time"
dateTime={n.createdAt.toISOString()}
>
{timeAgo(n.createdAt)}
</time>
</Tooltip>
</div>
{!n.read && (
<span className="notif-dot" aria-hidden="true" />
)}
</Link>
</li>
))}
</ul>

View File

@@ -1,8 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts";
import { deserializePlaylistWithDumps } from "../model.ts";
import type {
PlaylistWithDumps,
RawDump,
RawPlaylist,
RawPlaylistWithDumps,
} from "../model.ts";
import {
deserializeDump,
deserializePlaylist,
deserializePlaylistWithDumps,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { relativeTime } from "../utils/relativeTime.ts";
@@ -12,8 +22,10 @@ import { PageError } from "../components/PageError.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type LoadState =
@@ -31,10 +43,13 @@ export function PlaylistDetail() {
castVote,
removeVote,
deletedDumpIds,
lastDumpEvent,
lastPlaylistEvent,
} = useWS();
const [state, setState] = useState<LoadState>({ status: "loading" });
// Stable UUID for WS comparisons — avoids re-running effects on every state change
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
@@ -45,7 +60,9 @@ export function PlaylistDetail() {
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
const [dragSrcIndex, setDragSrcIndex] = useState<number | null>(null);
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
// without stale closure issues (state would only update on next render).
const dragSrcRef = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [editOpen, setEditOpen] = useState(false);
@@ -58,9 +75,18 @@ export function PlaylistDetail() {
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
// prevActiveDumpIds: used by the WS effect to diff incoming dumpIds
const prevActiveDumpIdsRef = useRef<Set<string> | null>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
// Mirrors activeDumpIds for use in effects without adding it as a dep.
// Updated on every render via useLayoutEffect so it's always current.
const activeDumpIdsRef = useRef(activeDumpIds);
// knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again)
const knownDumpIdsRef = useRef<Set<string>>(new Set());
// Authoritative dump order from the server (fetchPlaylist + dumps_updated events).
// Used to re-insert dumps at their correct position after private→public transitions.
const dumpOrderRef = useRef<string[]>([]);
useLayoutEffect(() => {
activeDumpIdsRef.current = activeDumpIds;
});
useEffect(() => () => {
cancels.current.forEach((c) => c());
@@ -87,8 +113,10 @@ export function PlaylistDetail() {
);
setState({ status: "loaded", playlist: pl });
const ids = new Set(pl.dumps.map((d) => d.id));
const order = pl.dumps.map((d) => d.id);
setActiveDumpIds(ids);
prevActiveDumpIdsRef.current = ids;
dumpOrderRef.current = order;
for (const id of ids) knownDumpIdsRef.current.add(id);
setFading({});
cancels.current.forEach((c) => c());
cancels.current.clear();
@@ -172,13 +200,17 @@ export function PlaylistDetail() {
// WS: playlist metadata updated or deleted
useEffect(() => {
if (!lastPlaylistEvent || !playlistId) return;
if (!lastPlaylistEvent || !playlistUUID) return;
const ev = lastPlaylistEvent;
if (ev.playlistId !== playlistId) return;
// Compare against the resolved UUID, not the URL param (which may be a slug)
if (ev.playlistId !== playlistUUID) return;
if (ev.type === "dumps_updated" && ev.dumpIds) {
const newIds = new Set(ev.dumpIds);
const prev = prevActiveDumpIdsRef.current ?? new Set<string>();
for (const id of newIds) knownDumpIdsRef.current.add(id);
// Use the ref so we always diff against the current activeDumpIds,
// including changes from deletedDumpIds / lastDumpEvent effects.
const prev = activeDumpIdsRef.current;
// Removed: were active, not in new set → fade out
for (const id of prev) {
@@ -192,46 +224,65 @@ export function PlaylistDetail() {
}
}
// Re-added while fading → cancel fade, restore to active
// Newly added IDs: cancel any fade, mark active, fetch dump data individually.
// We never call fetchPlaylist here — that would reset state to "loading", cycle
// playlistUUID, and re-trigger this effect in a loop.
for (const id of newIds) {
if (!prev.has(id)) {
if (cancels.current.has(id)) {
cancels.current.get(id)!();
}
// If this is a brand-new dump we haven't seen, re-fetch
setState((s) => {
if (s.status !== "loaded") return s;
const known = s.playlist.dumps.some((d) => d.id === id);
if (!known) {
// Trigger a re-fetch asynchronously
setTimeout(fetchPlaylist, 0);
}
return s;
});
cancels.current.get(id)?.();
setActiveDumpIds((s) => new Set([...s, id]));
// Capture ev.dumpIds so we can insert the new dump at its correct position.
const orderedIds = ev.dumpIds!;
fetch(`${API_URL}/api/dumps/${id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.ok ? r.json() : null)
.then((body) => {
if (!body?.success) return;
const dump = deserializeDump(body.data as RawDump);
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
// Insert at the correct server-ordered position.
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
dumpMap.set(dump.id, dump);
return {
...s,
playlist: {
...s.playlist,
dumps: [
...orderedIds
.filter((oid) => dumpMap.has(oid))
.map((oid) => dumpMap.get(oid)!),
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
],
},
};
});
})
.catch(() => {});
}
}
// Reorder active dumps to match the new server order,
// keeping fading dumps at their current visual positions.
setState((prev) => {
if (prev.status !== "loaded") return prev;
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
const activeQueue = ev.dumpIds!
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!);
let qi = 0;
const result = prev.playlist.dumps
.filter((d) => dumpMap.has(d.id))
.map((d) => newIds.has(d.id) ? activeQueue[qi++] : d);
while (qi < activeQueue.length) result.push(activeQueue[qi++]);
// Apply the server-authoritative order: active dumps in ev.dumpIds order,
// fading dumps (not in newIds) appended at the end.
setState((s) => {
if (s.status !== "loaded") return s;
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
return {
...prev,
playlist: { ...prev.playlist, dumps: result },
...s,
playlist: {
...s.playlist,
dumps: [
...ev.dumpIds!
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!),
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
],
},
};
});
prevActiveDumpIdsRef.current = newIds;
dumpOrderRef.current = ev.dumpIds!;
} else if (ev.type === "updated" && ev.playlist) {
setState((prev) => {
if (prev.status !== "loaded") return prev;
@@ -249,7 +300,7 @@ export function PlaylistDetail() {
} else if (ev.type === "deleted") {
navigate("/");
}
}, [lastPlaylistEvent, playlistId]);
}, [lastPlaylistEvent, playlistUUID]);
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
useEffect(() => {
@@ -269,36 +320,85 @@ export function PlaylistDetail() {
});
}, [deletedDumpIds]);
const handleDragStart = (index: number) => setDragSrcIndex(index);
// Update dump metadata in-place; re-add if it was in this playlist but hidden (private→public)
useEffect(() => {
if (!lastDumpEvent) return;
const dump = lastDumpEvent;
setState((prev) => {
if (prev.status !== "loaded") return prev;
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
if (idx !== -1) {
// Update in-place
const dumps = [...prev.playlist.dumps];
dumps[idx] = dump;
return { ...prev, playlist: { ...prev.playlist, dumps } };
}
// Re-add if this dump belongs to the playlist and is now public,
// inserting at its correct server-ordered position.
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
const order = dumpOrderRef.current;
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
dumpMap.set(dump.id, dump);
const reinserted = order.length > 0
? [
...order.filter((id) => dumpMap.has(id)).map((id) => dumpMap.get(id)!),
...prev.playlist.dumps.filter((d) => !new Set(order).has(d.id)),
]
: [...prev.playlist.dumps, dump];
return { ...prev, playlist: { ...prev.playlist, dumps: reinserted } };
}
return prev;
});
// Restore to activeDumpIds if re-added
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
setActiveDumpIds((prev) => {
if (prev.has(dump.id)) return prev;
return new Set([...prev, dump.id]);
});
}
}, [lastDumpEvent]);
const handleDragStart = (index: number) => {
dragSrcRef.current = index;
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (dragSrcIndex === null || dragSrcIndex === index) return;
const src = dragSrcRef.current;
if (src === null || src === index) return;
// Only swap once the pointer has crossed the card's midpoint.
// Without this, entering a card immediately re-triggers the swap in the
// opposite direction (the two items keep bouncing back and forth).
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (src < index && e.clientY < mid) return; // dragging downward, not past mid yet
if (src > index && e.clientY > mid) return; // dragging upward, not past mid yet
// Update visual order in state. Use activeDumpIdsRef so the updater never
// reads a stale closure — activeDumpIds can't change mid-drag but this is
// the correct pattern for setState updaters.
setState((prev) => {
if (prev.status !== "loaded") return prev;
// Only reorder among active dumps
const activeDumps = prev.playlist.dumps.filter((d) =>
activeDumpIds.has(d.id)
);
const fadingDumps = prev.playlist.dumps.filter((d) =>
!activeDumpIds.has(d.id)
);
const ids = activeDumpIdsRef.current;
const activeDumps = prev.playlist.dumps.filter((d) => ids.has(d.id));
const fadingDumps = prev.playlist.dumps.filter((d) => !ids.has(d.id));
const reordered = [...activeDumps];
const [moved] = reordered.splice(dragSrcIndex, 1);
const [moved] = reordered.splice(src, 1);
reordered.splice(index, 0, moved);
setDragSrcIndex(index);
setDragOverIndex(index);
return {
...prev,
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
};
});
// Update the ref and highlight index outside the updater (no side effects inside updaters).
dragSrcRef.current = index;
setDragOverIndex(index);
};
const handleDragEnd = async () => {
if (state.status !== "loaded" || !playlistId) return;
setDragSrcIndex(null);
const src = dragSrcRef.current;
dragSrcRef.current = null;
setDragOverIndex(null);
if (src === null || state.status !== "loaded" || !playlistId) return;
const activeDumps = state.playlist.dumps.filter((d) =>
activeDumpIds.has(d.id)
);
@@ -341,13 +441,6 @@ export function PlaylistDetail() {
}).catch(() => {});
};
useEffect(() => {
const el = descriptionRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [editDescription, editOpen]);
const openEdit = () => {
if (state.status !== "loaded") return;
setEditTitle(state.playlist.title);
@@ -364,15 +457,25 @@ export function PlaylistDetail() {
setEditSaving(true);
setEditError(null);
try {
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: editTitle,
description: editDescription || undefined,
isPublic: editIsPublic,
}),
});
const updateRes = await authFetch(
`${API_URL}/api/playlists/${playlistId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: editTitle,
description: editDescription || undefined,
isPublic: editIsPublic,
}),
},
);
const updateJson = await updateRes.json() as {
success: boolean;
data: RawPlaylist;
};
const updatedPlaylist = updateJson.success
? deserializePlaylist(updateJson.data)
: null;
if (imageFile) {
const fd = new FormData();
@@ -384,7 +487,11 @@ export function PlaylistDetail() {
}
setEditOpen(false);
fetchPlaylist();
if (updatedPlaylist) {
navigate(playlistUrl(updatedPlaylist), { replace: true });
} else {
fetchPlaylist();
}
} catch (err) {
setEditError(friendlyFetchError(err));
} finally {
@@ -519,12 +626,12 @@ export function PlaylistDetail() {
{editOpen
? (
<textarea
ref={descriptionRef}
<TextEditor
className="playlist-edit-textarea"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
onChange={setEditDescription}
placeholder="Description (optional)"
autoResize
rows={1}
/>
)
@@ -571,12 +678,18 @@ export function PlaylistDetail() {
@{playlist.ownerUsername}
</Link>
)}
<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>
{playlist.updatedAt && (
<Tooltip text={`Edited ${playlist.updatedAt.toLocaleString()}`}>
<span className="playlist-edited-label">
edited {relativeTime(playlist.updatedAt)}
</span>
</Tooltip>
)}
</>
)}
</div>
@@ -590,7 +703,10 @@ export function PlaylistDetail() {
{visibleDumps.length === 0
? <p className="empty-state">No dumps in this playlist yet.</p>
: (
<div className="playlist-dump-list">
<div
className="playlist-dump-list"
onDragOver={isOwner ? (e) => e.preventDefault() : undefined}
>
{visibleDumps.map((dump) => {
const isActive = activeDumpIds.has(dump.id);
const phase = fading[dump.id];
@@ -617,7 +733,7 @@ export function PlaylistDetail() {
onDragOver={isOwner && isActive
? (e) => handleDragOver(e, activeIndex)
: undefined}
onDragEnd={isOwner && isActive ? handleDragEnd : undefined}
onDragEnd={isOwner ? handleDragEnd : undefined}
>
{isOwner && isActive && (
<span className="drag-handle" aria-hidden></span>

View File

@@ -13,6 +13,7 @@ import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
@@ -49,6 +50,19 @@ export function UserDumps() {
const [state, setState] = useState<State>({ status: "loading" });
const [createModalOpen, setCreateModalOpen] = useState(false);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const isOwnProfile = me?.id === profileUserId;
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
}, []);
const addFilter = useCallback((dump: Dump): boolean => {
if (!profileUserId) return false;
if (dump.userId !== profileUserId) return false;
return isOwnProfile || !dump.isPrivate;
}, [profileUserId, isOwnProfile]);
useDumpListSync(setDumps, addFilter);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
@@ -197,7 +211,6 @@ export function UserDumps() {
}
const { profileUser, dumps, hasMore, loadingMore } = state;
const isOwnProfile = me?.username === profileUser.username;
return (
<PageShell>

View File

@@ -87,7 +87,7 @@ export function UserLogin() {
</form>
<p className="auth-card-footer">
No account? <Link to="/register">Register</Link>
This is a mirage.
</p>
</div>
</PageShell>

View File

@@ -17,7 +17,7 @@ import type {
} from "../model.ts";
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
@@ -55,7 +55,6 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
export function UserPlaylists() {
const { username } = useParams();
const { user: me, authFetch, token } = useAuth();
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
Playlist
@@ -73,6 +72,28 @@ export function UserPlaylists() {
const [state, setState] = useState<State>({ status: "loading" });
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const isOwnProfile = me?.id === profileUserId;
const setCreated = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
s.status !== "loaded" ? s : { ...s, created: { ...s.created, items: fn(s.created.items) } }
);
}, []);
usePlaylistListSync(setCreated, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
});
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
s.status !== "loaded"
? s
: { ...s, followed: { ...s.followed, items: fn(s.followed.items) } }
);
}, []);
usePlaylistListSync(setFollowed, { noNewEntries: true });
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
@@ -246,77 +267,6 @@ export function UserPlaylists() {
!state.followed.loadingMore,
);
// Real-time WS playlist updates
useEffect(() => {
if (!lastPlaylistEvent || state.status !== "loaded") return;
const ev = lastPlaylistEvent;
const isOwnProfile = me?.username === state.profileUser.username;
if (ev.type === "created" && ev.playlist?.userId === state.profileUser.id) {
if (ev.playlist.isPublic || isOwnProfile) {
setState((s) => {
if (s.status !== "loaded") return s;
if (s.created.items.some((p) => p.id === ev.playlist!.id)) return s;
return {
...s,
created: {
...s.created,
items: [ev.playlist!, ...s.created.items],
},
};
});
}
} else if (ev.type === "updated") {
setState((s) => {
if (s.status !== "loaded") return s;
const updatedCreated = ev.playlist?.userId === state.profileUser.id
? s.created.items
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
.filter((p) => p.isPublic || isOwnProfile)
: s.created.items;
const updatedFollowed = s.followed.items.map((p) =>
p.id === ev.playlist?.id ? ev.playlist! : p
).filter((p) => p.isPublic);
return {
...s,
created: { ...s.created, items: updatedCreated },
followed: { ...s.followed, items: updatedFollowed },
};
});
} else if (ev.type === "deleted") {
setState((s) =>
s.status !== "loaded" ? s : {
...s,
created: {
...s.created,
items: s.created.items.filter((p) => p.id !== ev.playlistId),
},
followed: {
...s.followed,
items: s.followed.items.filter((p) => p.id !== ev.playlistId),
},
}
);
}
}, [lastPlaylistEvent, me]);
useEffect(() => {
if (!deletedPlaylistIds.size || state.status !== "loaded") return;
setState((s) =>
s.status !== "loaded" ? s : {
...s,
created: {
...s.created,
items: s.created.items.filter((p) => !deletedPlaylistIds.has(p.id)),
},
followed: {
...s.followed,
items: s.followed.items.filter((p) => !deletedPlaylistIds.has(p.id)),
},
}
);
}, [deletedPlaylistIds]);
// Scroll save
useEffect(() => {
if (state.status !== "loaded") return;
@@ -395,7 +345,6 @@ export function UserPlaylists() {
}
const { profileUser, created, followed } = state;
const isOwnProfile = me?.username === profileUser.username;
return (
<PageShell>

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>

View File

@@ -13,6 +13,7 @@ import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
@@ -46,6 +47,12 @@ export function UserUpvoted() {
);
const [state, setState] = useState<State>({ status: "loading" });
const setVotesDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, votes: fn(s.votes) });
}, []);
useDumpListSync(setVotesDumps);
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">

7
src/utils/urls.ts Normal file
View File

@@ -0,0 +1,7 @@
export function dumpUrl(dump: { id: string; slug?: string }): string {
return `/dumps/${dump.slug ?? dump.id}`;
}
export function playlistUrl(playlist: { id: string; slug?: string }): string {
return `/playlists/${playlist.slug ?? playlist.id}`;
}