v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -3,15 +3,11 @@ import { createPortal } from "react-dom";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type {
CreatePlaylistRequest,
PlaylistMembership,
RawPlaylist,
RawPlaylistMembership,
} from "../model.ts";
import {
deserializePlaylist,
deserializePlaylistMembership,
} from "../model.ts";
import { deserializePlaylistMembership } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface AddToPlaylistModalProps {
dumpId: string;
@@ -25,10 +21,6 @@ export function AddToPlaylistModal(
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [loading, setLoading] = useState(true);
const [showNewForm, setShowNewForm] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newIsPublic, setNewIsPublic] = useState(true);
const [creating, setCreating] = useState(false);
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -87,41 +79,6 @@ export function AddToPlaylistModal(
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
setCreating(true);
try {
const req: CreatePlaylistRequest = {
title: newTitle.trim(),
description: newDescription.trim() || undefined,
isPublic: newIsPublic,
};
const res = await authFetch(`${API_URL}/api/playlists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
const body = await res.json();
if (!body.success) return;
const playlist = deserializePlaylist(body.data as RawPlaylist);
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{
method: "POST",
},
);
setMemberships((prev) => [{ playlist, hasDump: true }, ...prev]);
setNewTitle("");
setNewDescription("");
setShowNewForm(false);
} finally {
setCreating(false);
}
};
return createPortal(
<div
className="modal-backdrop"
@@ -176,49 +133,17 @@ export function AddToPlaylistModal(
{showNewForm
? (
<form className="modal-new-playlist-form" onSubmit={handleCreate}>
<input
type="text"
placeholder="Title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
autoFocus
required
/>
<textarea
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
rows={2}
/>
<div className="dump-mode-toggle">
<button
type="button"
className={newIsPublic ? "active" : ""}
onClick={() => setNewIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!newIsPublic ? "active" : ""}
onClick={() => setNewIsPublic(false)}
>
Private
</button>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="submit" disabled={creating}>
{creating ? "Creating…" : "Create & Add"}
</button>
<button
type="button"
onClick={() => setShowNewForm(false)}
>
Cancel
</button>
</div>
</form>
<PlaylistCreateForm
dumpId={dumpId}
onCreated={(playlist) => {
setMemberships((prev) => [
{ playlist, hasDump: true },
...prev,
]);
setShowNewForm(false);
}}
onCancel={() => setShowNewForm(false)}
/>
)
: (
<button

View File

@@ -2,12 +2,14 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link, useNavigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
const { user } = useAuth();
const navigate = useNavigate();
const headerRef = useRef<HTMLElement>(null);
const [showFab, setShowFab] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
useEffect(() => {
const el = headerRef.current;
@@ -46,7 +48,7 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
<button
type="button"
className="btn-primary"
onClick={() => navigate("/dumps/new")}
onClick={() => setCreateModalOpen(true)}
>
+ New
</button>
@@ -69,16 +71,20 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
</nav>
</header>
{user && createPortal(
{/* {user && createPortal(
<button
type="button"
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
onClick={() => navigate("/dumps/new")}
onClick={() => setCreateModalOpen(true)}
aria-label="New dump"
>
+ New
</button>,
document.body,
)} */}
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
</>
);

View File

@@ -0,0 +1,353 @@
import { useRef, useState } from "react";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Comment, RawComment, User } from "../model.ts";
import { deserializeComment } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx";
import { relativeTime } from "../utils/relativeTime.ts";
interface CommentThreadProps {
dumpId: string;
comments: Comment[];
currentUser: User | null;
token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
}
function buildTree(comments: Comment[]): Map<string, Comment[]> {
const map = new Map<string, Comment[]>();
for (const c of comments) {
const key = c.parentId ?? "root";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(c);
}
return map;
}
const MAX_INDENT_DEPTH = 4;
interface CommentNodeProps {
comment: Comment;
tree: Map<string, Comment[]>;
depth: number;
dumpId: string;
currentUser: User | null;
token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
}
function CommentNode({
comment,
tree,
depth,
dumpId,
currentUser,
token,
onCommentCreated,
onCommentDeleted,
}: 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 children = tree.get(comment.id) ?? [];
async function handleReply(e: React.FormEvent) {
e.preventDefault();
if (!replyBody.trim() || !token) return;
setSubmitting(true);
setReplyError(null);
try {
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: replyBody, parentId: comment.id }),
});
const data = await res.json();
if (data.success) {
onCommentCreated(deserializeComment(data.data as RawComment));
setReplyBody("");
setReplyOpen(false);
} else {
setReplyError(data.error?.message ?? "Failed to post reply.");
}
} catch {
setReplyError("Could not reach the server. Please try again.");
} finally {
setSubmitting(false);
}
}
async function handleDelete() {
if (!token) return;
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => null);
if (res?.ok) {
onCommentDeleted(comment.id);
}
}
const canDelete = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
if (comment.deleted) {
return (
<li className="comment-node">
<div className="comment-node-inner comment-node-inner--deleted">
<div className="comment-avatar comment-avatar--deleted">
<div className="comment-avatar-placeholder" style={{ width: 28, height: 28 }} />
</div>
<div className="comment-content">
<p className="comment-deleted-placeholder">[deleted]</p>
</div>
</div>
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
>
{children.map((child) => (
<CommentNode
key={child.id}
comment={child}
tree={tree}
depth={depth + 1}
dumpId={dumpId}
currentUser={currentUser}
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
/>
))}
</ul>
)}
</li>
);
}
return (
<li className="comment-node">
<div className="comment-node-inner">
<div className="comment-avatar">
<Avatar
userId={comment.userId}
username={comment.authorUsername}
hasAvatar={!!comment.authorAvatarMime}
size={28}
/>
</div>
<div className="comment-content">
<div className="comment-meta">
<Link
to={`/users/${comment.authorUsername}`}
className="comment-author"
>
{comment.authorUsername}
</Link>
<time
className="comment-time"
dateTime={comment.createdAt.toISOString()}
title={comment.createdAt.toLocaleString()}
>
{relativeTime(comment.createdAt)}
</time>
</div>
<Markdown className="comment-body">{comment.body}</Markdown>
<div className="comment-actions">
{currentUser && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setReplyOpen((v) => !v);
setTimeout(() => replyTextareaRef.current?.focus(), 0);
}}
>
Reply
</button>
)}
{canDelete && (
<button
type="button"
className="comment-action-btn comment-delete-btn"
onClick={handleDelete}
>
Delete
</button>
)}
</div>
{replyOpen && (
<form className="comment-form" onSubmit={handleReply}>
<textarea
ref={replyTextareaRef}
className="comment-reply-textarea"
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
}}
placeholder="Write a reply…"
rows={3}
/>
{replyError && (
<p className="comment-form-error">{replyError}</p>
)}
<div className="comment-form-actions">
<button
type="submit"
className="comment-submit-btn"
disabled={submitting || !replyBody.trim()}
>
{submitting ? "Posting…" : "Post reply"}
</button>
<button
type="button"
className="comment-action-btn"
onClick={() => {
setReplyOpen(false);
setReplyBody("");
setReplyError(null);
}}
>
Cancel
</button>
</div>
</form>
)}
</div>
</div>
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH
? { paddingLeft: 0 }
: undefined}
>
{children.map((child) => (
<CommentNode
key={child.id}
comment={child}
tree={tree}
depth={depth + 1}
dumpId={dumpId}
currentUser={currentUser}
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
/>
))}
</ul>
)}
</li>
);
}
export function CommentThread({
dumpId,
comments,
currentUser,
token,
onCommentCreated,
onCommentDeleted,
}: CommentThreadProps) {
const [topLevelBody, setTopLevelBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [topLevelError, setTopLevelError] = useState<string | null>(null);
const tree = buildTree(comments);
const roots = tree.get("root") ?? [];
async function handleTopLevelSubmit(e: React.FormEvent) {
e.preventDefault();
if (!topLevelBody.trim() || !token) return;
setSubmitting(true);
setTopLevelError(null);
try {
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: topLevelBody }),
});
const data = await res.json();
if (data.success) {
onCommentCreated(deserializeComment(data.data as RawComment));
setTopLevelBody("");
} else {
setTopLevelError(data.error?.message ?? "Failed to post comment.");
}
} catch {
setTopLevelError("Could not reach the server. Please try again.");
} finally {
setSubmitting(false);
}
}
return (
<section className="comment-section">
<h2 className="comment-section-title">
{(() => {
const n = comments.filter((c) => !c.deleted).length;
return n === 1 ? "1 comment" : `${n} comments`;
})()}
</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 && (
<p className="comment-form-error">{topLevelError}</p>
)}
<div className="comment-form-actions">
<button
type="submit"
className="comment-submit-btn"
disabled={submitting || !topLevelBody.trim()}
>
{submitting ? "Posting…" : "Post comment"}
</button>
</div>
</form>
)}
{roots.length > 0 && (
<ul className="comment-list">
{roots.map((comment) => (
<CommentNode
key={comment.id}
comment={comment}
tree={tree}
depth={0}
dumpId={dumpId}
currentUser={currentUser}
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
/>
))}
</ul>
)}
</section>
);
}

View File

@@ -4,6 +4,7 @@ import { relativeTime } from "../utils/relativeTime.ts";
import FilePreview from "./FilePreview.tsx";
import RichContentCard from "./RichContentCard.tsx";
import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx";
interface DumpCardProps {
dump: Dump;
@@ -13,10 +14,11 @@ interface DumpCardProps {
castVote: (id: string) => void;
removeVote: (id: string) => void;
className?: string;
isOwner?: boolean;
}
export function DumpCard(
{ dump, voteCount, voted, canVote, castVote, removeVote, className }:
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
DumpCardProps,
) {
const navigate = useNavigate();
@@ -46,14 +48,26 @@ export function DumpCard(
>
{dump.title}
</Link>
{dump.comment && <p className="dump-card-comment">{dump.comment}</p>}
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
title={dump.createdAt.toLocaleString()}
>
{relativeTime(dump.createdAt)}
</time>
{dump.comment && (
<Markdown className="dump-card-comment" inline>{dump.comment}</Markdown>
)}
<div className="dump-card-meta">
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
title={dump.createdAt.toLocaleString()}
>
{relativeTime(dump.createdAt)}
</time>
{dump.commentCount > 0 && (
<span className="dump-card-comment-count">
{dump.commentCount} {dump.commentCount === 1 ? "comment" : "comments"}
</span>
)}
{dump.isPrivate && isOwner && (
<span className="dump-card-private-badge">private</span>
)}
</div>
</div>
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>

View File

@@ -0,0 +1,536 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
import type {
CreateUrlDumpRequest,
Dump,
PlaylistMembership,
RawDump,
RawPlaylistMembership,
} from "../model.ts";
import {
deserializeDump,
deserializePlaylistMembership,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts";
import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
const MAX_FILE_SIZE = 50 * 1024 * 1024;
type Mode = "url" | "file";
type Phase = "create" | "playlist";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
type UrlPreview =
| { status: "idle" }
| { status: "loading" }
| { status: "done"; richContent: RichContent | null };
function LocalFilePreview({ file }: { file: File }) {
const [src, setSrc] = useState<string | null>(null);
const mime = file.type;
useEffect(() => {
const url = URL.createObjectURL(file);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return null;
if (mime.startsWith("image/")) {
return <img src={src} alt={file.name} className="local-preview-image" />;
}
if (mime.startsWith("video/")) {
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
}
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>
);
}
interface DumpCreateModalProps {
onClose: () => void;
}
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
const { authFetch } = useAuth();
const backdropRef = useRef<HTMLDivElement>(null);
const [phase, setPhase] = useState<Phase>("create");
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
// Create phase state
const [mode, setMode] = useState<Mode>("url");
const [url, setUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [submitState, setSubmitState] = useState<SubmitState>({
status: "idle",
});
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Playlist phase state
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const [showNewPlaylistForm, setShowNewPlaylistForm] = useState(false);
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
// Escape key to close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
// Debounced URL preview
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
let trimmed: string;
try {
const u = new URL(url.trim());
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
trimmed = u.toString();
} catch {
setUrlPreview({ status: "idle" });
return;
}
setUrlPreview({ status: "loading" });
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`,
);
const body = await res.json();
setUrlPreview({
status: "done",
richContent: body.success ? body.data : null,
});
} catch {
setUrlPreview({ status: "done", richContent: null });
}
}, 600);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [url]);
// Paste handler
useEffect(() => {
const handler = (e: ClipboardEvent) => {
const pastedFile = e.clipboardData?.files[0];
if (pastedFile) {
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setFile(pastedFile);
setSubmitState({ status: "idle" });
return;
}
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const text = e.clipboardData?.getData("text") ?? "";
try {
const u = new URL(text.trim());
if (u.protocol === "http:" || u.protocol === "https:") {
setMode("url");
setFile(null);
setUrl(text.trim());
setSubmitState({ status: "idle" });
}
} catch { /* not a URL */ }
};
globalThis.addEventListener("paste", handler);
return () => globalThis.removeEventListener("paste", handler);
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSubmitState({ status: "submitting" });
try {
let res: Response;
if (mode === "url") {
if (!url.trim()) {
setSubmitState({ status: "error", error: "URL is required." });
return;
}
const body: CreateUrlDumpRequest = {
url: url.trim(),
comment: comment.trim() || undefined,
isPrivate,
};
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: JSON.stringify(body),
});
} else {
if (!file) {
setSubmitState({ status: "error", error: "Please select a file." });
return;
}
if (file.size > MAX_FILE_SIZE) {
setSubmitState({
status: "error",
error: "File too large (max 50 MB).",
});
return;
}
const formData = new FormData();
formData.append("file", file);
if (comment.trim()) formData.append("comment", comment.trim());
formData.append("isPrivate", String(isPrivate));
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: formData,
});
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
if (apiResponse.success) {
const dump = deserializeDump(apiResponse.data as RawDump);
setCreatedDump(dump);
setPhase("playlist");
setPlaylistsLoading(true);
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
.then((r) => r.json())
.then((body) => {
if (body.success) {
setMemberships(
(body.data as RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
} else {
setSubmitState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setSubmitState({
status: "error",
error: err instanceof Error ? err.message : "Failed to create dump.",
});
}
};
const toggleMembership = async (membership: PlaylistMembership) => {
if (!createdDump) return;
const { playlist, hasDump } = membership;
if (hasDump) {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: "DELETE" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
)
);
} else {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
)
);
}
};
const submitting = submitState.status === "submitting";
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
>
<div className="modal-card modal-card--wide">
<div className="modal-header">
<span className="modal-title">
{phase === "create" ? "New dump" : "Add to playlist"}
</span>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{phase === "create"
? (
<>
<div className="dump-mode-toggle">
<button
type="button"
className={mode === "url" ? "active" : ""}
onClick={() => {
setMode("url");
setFile(null);
setSubmitState({ status: "idle" });
}}
disabled={submitting}
>
🔗 URL
</button>
<button
type="button"
className={mode === "file" ? "active" : ""}
onClick={() => {
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setSubmitState({ status: "idle" });
}}
disabled={submitting}
>
📎 File
</button>
</div>
<form onSubmit={handleSubmit} className="dump-form">
{submitState.status === "error" && (
<p className="form-error">{submitState.error}</p>
)}
{mode === "url"
? (
<>
<div className="form-group">
<label htmlFor="dc-url">URL</label>
<input
id="dc-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setFile(pastedFile);
setSubmitState({ status: "idle" });
}
}}
disabled={submitting}
placeholder="https://..."
required
autoFocus
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">Fetching preview</p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
<RichContentCard richContent={urlPreview.richContent} />
)}
</>
)
: (
<>
<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>
{file && <LocalFilePreview file={file} />}
</>
)}
<div className="form-group">
<label htmlFor="dc-comment">
Why are you dumping this?
</label>
<textarea
id="dc-comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={submitting}
placeholder="Tell the community what makes this worth their time..."
rows={3}
/>
</div>
<label className="toggle-row">
<span className="toggle-label">Public</span>
<span className="toggle-switch">
<input
type="checkbox"
checked={!isPrivate}
onChange={(e) => setIsPrivate(!e.target.checked)}
disabled={submitting}
/>
<span className="toggle-thumb" />
</span>
{isPrivate && (
<span className="toggle-hint">Only visible to you</span>
)}
</label>
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="form-cancel"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting}
>
{submitting
? (mode === "url" ? "Fetching…" : "Uploading…")
: "Dump it"}
</button>
</div>
</div>
</form>
</>
)
: (
<>
{createdDump && (
<p className="dump-create-success">
Dumped!{" "}
<Link to={`/dumps/${createdDump.id}`} onClick={onClose}>
View dump
</Link>
</p>
)}
{playlistsLoading
? <p className="page-loading">Loading playlists</p>
: memberships.length === 0 && !showNewPlaylistForm
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="playlist-membership-list">
{memberships.map((m) => (
<li
key={m.playlist.id}
className={`playlist-membership-row${
m.hasDump ? " playlist-membership-row--active" : ""
}`}
onClick={() => toggleMembership(m)}
>
<span className="playlist-membership-check">
{m.hasDump ? "✓" : "○"}
</span>
<span className="playlist-membership-name">
{m.playlist.title}
</span>
{!m.playlist.isPublic && (
<span className="playlist-badge playlist-badge--private">
private
</span>
)}
</li>
))}
</ul>
)}
{showNewPlaylistForm
? (
<PlaylistCreateForm
dumpId={createdDump?.id}
onCreated={(playlist) => {
setMemberships((prev) => [
{ playlist, hasDump: true },
...prev,
]);
setShowNewPlaylistForm(false);
}}
onCancel={() => setShowNewPlaylistForm(false)}
/>
)
: (
<button
type="button"
className="modal-new-playlist-toggle"
onClick={() => setShowNewPlaylistForm(true)}
>
+ New playlist
</button>
)}
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-primary"
onClick={onClose}
>
Done
</button>
</div>
</div>
</>
)}
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,62 @@
import { useContext, useEffect, useRef, useState } from "react";
import { PlayerContext } from "../contexts/PlayerContext.ts";
export function GlobalPlayer() {
const { current, stop } = useContext(PlayerContext);
const ref = useRef<HTMLDivElement>(null);
const [reduced, setReduced] = useState(false);
useEffect(() => {
if (!current) {
document.body.classList.remove("has-player");
document.body.style.removeProperty("--player-height");
return;
}
const el = ref.current;
if (!el) return;
document.body.style.setProperty("--player-height", `${el.offsetHeight}px`);
document.body.classList.add("has-player");
const observer = new ResizeObserver(() => {
document.body.style.setProperty("--player-height", `${el.offsetHeight}px`);
});
observer.observe(el);
return () => {
observer.disconnect();
document.body.classList.remove("has-player");
document.body.style.removeProperty("--player-height");
};
}, [current]);
useEffect(() => {
if (current) setReduced(false);
}, [current?.embedUrl]);
if (!current) return null;
return (
<div className={`global-player global-player--${current.type}${reduced ? " global-player--reduced" : ""}`} ref={ref}>
<div className="global-player-header">
<span className="global-player-title">{current.title ?? current.embedUrl}</span>
<button className="btn btn--ghost" onClick={() => setReduced((r) => !r)}>
{reduced ? "▲" : "▼"}
</button>
<button className="btn btn--ghost" onClick={stop}>
</button>
</div>
<div className="global-player-body">
<div className="global-player-iframe-wrap">
<iframe
src={current.embedUrl}
className={`global-player-iframe--${current.type}`}
allow="autoplay; encrypted-media"
allowFullScreen
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useRef } from "react";
interface ImagePickerProps {
src: string | null;
alt?: string;
size?: number;
borderRadius?: number;
onChange: (file: File) => void;
uploading?: boolean;
accept?: string;
}
export function ImagePicker({
src,
alt = "",
size = 80,
borderRadius = 8,
onChange,
uploading = false,
accept = "image/jpeg,image/png,image/gif,image/webp",
}: ImagePickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const sizeStyle = { width: size, height: size, borderRadius };
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) onChange(file);
e.target.value = "";
};
return (
<div
className="img-picker"
style={sizeStyle}
onClick={() => !uploading && inputRef.current?.click()}
title={src ? "Change image" : "Add image"}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
>
{src
? (
<img
src={src}
alt={alt}
className="img-picker-img"
style={{ borderRadius }}
/>
)
: (
<div className="img-picker-placeholder" style={{ borderRadius }}>
<span>+</span>
</div>
)}
<div className="img-picker-overlay" style={{ borderRadius }}>
{uploading ? "…" : "✎"}
</div>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
disabled={uploading}
style={{ display: "none" }}
/>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface MarkdownProps {
children: string;
className?: string;
inline?: boolean;
}
const REMARK_PLUGINS = [remarkGfm];
export function Markdown({ children, className, inline = false }: MarkdownProps) {
return (
<div className={`md${className ? ` ${className}` : ""}${inline ? " md--inline" : ""}`}>
<ReactMarkdown
remarkPlugins={REMARK_PLUGINS}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{children}
</ReactMarkdown>
</div>
);
}

View File

@@ -1,9 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import type { Playlist } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface NewPlaylistFormProps {
onCreated: (playlist: Playlist) => void;
@@ -18,15 +16,11 @@ export function NewPlaylistForm(
toggleClassName = "new-playlist-toggle",
}: NewPlaylistFormProps,
) {
const { authFetch } = useAuth();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const close = () => setOpen(false);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
@@ -44,43 +38,6 @@ export function NewPlaylistForm(
return () => document.removeEventListener("keydown", handler);
}, [open]);
const close = () => {
setOpen(false);
setTitle("");
setDescription("");
setIsPublic(true);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
setSubmitting(true);
setError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
description: description.trim() || undefined,
isPublic,
}),
});
const body = await res.json();
if (!body.success) {
setError(body.error?.message ?? "Failed to create playlist");
return;
}
onCreated(deserializePlaylist(body.data as RawPlaylist));
close();
} catch {
setError("Failed to create playlist");
} finally {
setSubmitting(false);
}
};
return (
<>
<button
@@ -112,45 +69,13 @@ export function NewPlaylistForm(
</button>
</div>
<div className="modal-body">
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
required
/>
<textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<div className="dump-mode-toggle">
<button
type="button"
className={isPublic ? "active" : ""}
onClick={() => setIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!isPublic ? "active" : ""}
onClick={() => setIsPublic(false)}
>
Private
</button>
</div>
{error && <p className="form-error">{error}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</button>
<button type="button" onClick={close}>Cancel</button>
</div>
</form>
<PlaylistCreateForm
onCreated={(playlist) => {
onCreated(playlist);
close();
}}
onCancel={close}
/>
</div>
</div>
</div>,

View File

@@ -0,0 +1,112 @@
import { useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */
dumpId?: string;
onCreated: (playlist: Playlist) => void;
onCancel: () => void;
}
export function PlaylistCreateForm(
{ dumpId, onCreated, onCancel }: PlaylistCreateFormProps,
) {
const { authFetch } = useAuth();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
setSubmitting(true);
setError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
description: description.trim() || undefined,
isPublic,
}),
});
const body = await res.json();
if (!body.success) {
setError(body.error?.message ?? "Failed to create playlist");
return;
}
const playlist = deserializePlaylist(body.data as RawPlaylist);
if (dumpId) {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "POST" },
);
}
onCreated(playlist);
} catch {
setError("Failed to create playlist");
} finally {
setSubmitting(false);
}
};
return (
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
required
/>
<textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<div className="dump-mode-toggle">
<button
type="button"
className={isPublic ? "active" : ""}
onClick={() => setIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!isPublic ? "active" : ""}
onClick={() => setIsPublic(false)}
>
Private
</button>
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-secondary"
onClick={onCancel}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting}
>
{submitting ? "Creating…" : dumpId ? "Create & Add" : "Create"}
</button>
</div>
</div>
</form>
);
}

View File

@@ -1,4 +1,6 @@
import { useContext } from "react";
import type { RichContent } from "../model.ts";
import { PlayerContext } from "../contexts/PlayerContext.ts";
interface RichContentCardProps {
richContent: RichContent;
@@ -8,6 +10,8 @@ interface RichContentCardProps {
export default function RichContentCard(
{ richContent, compact = false }: RichContentCardProps,
) {
const { play } = useContext(PlayerContext);
if (compact) {
return (
<a
@@ -33,14 +37,34 @@ export default function RichContentCard(
);
}
return (
<a
href={richContent.url}
target="_blank"
rel="noopener noreferrer"
className={`rich-content-card rich-content-card--${richContent.type}`}
>
{richContent.thumbnailUrl && (
const canPlay = !!richContent.embedUrl;
const thumbnail = richContent.thumbnailUrl && (
canPlay
? (
<button
type="button"
className="rich-content-thumbnail-btn"
onClick={() =>
play({
embedUrl: richContent.embedUrl!,
title: richContent.title,
type: richContent.type,
})}
aria-label="Play"
>
<img
src={richContent.thumbnailUrl}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<span className="rich-content-play-overlay"></span>
</button>
)
: (
<img
src={richContent.thumbnailUrl}
alt={richContent.title ?? ""}
@@ -49,8 +73,18 @@ export default function RichContentCard(
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<div className="rich-content-body">
)
);
return (
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
{thumbnail}
<a
href={richContent.url}
target="_blank"
rel="noopener noreferrer"
className="rich-content-body"
>
{richContent.siteName && (
<span className="rich-content-badge">{richContent.siteName}</span>
)}
@@ -58,10 +92,12 @@ export default function RichContentCard(
<p className="rich-content-title">{richContent.title}</p>
)}
{richContent.description && (
<p className="rich-content-description">{richContent.description}</p>
<p className="rich-content-description">
{richContent.description}
</p>
)}
<span className="rich-content-url">{richContent.url}</span>
</div>
</a>
</a>
</div>
);
}