v3: code quality pass

This commit is contained in:
khannurien
2026-03-24 18:47:05 +00:00
parent cd4076343b
commit c293f3e706
39 changed files with 1464 additions and 1555 deletions

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
import { deserializePlaylistMembership } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
interface AddToPlaylistModalProps {
dumpId: string;
@@ -17,23 +17,6 @@ export function AddToPlaylistModal(
const { authFetch } = useAuth();
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [loading, setLoading] = useState(true);
const [showNewForm, setShowNewForm] = useState(false);
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
useEffect(() => {
authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`)
@@ -76,84 +59,16 @@ export function AddToPlaylistModal(
}
};
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
>
<div className="modal-card">
<div className="modal-header">
<span className="modal-title">Add to playlist</span>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{loading
? <p className="page-loading">Loading</p>
: memberships.length === 0 && !showNewForm
? <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>
)}
{showNewForm
? (
<PlaylistCreateForm
dumpId={dumpId}
onCreated={(playlist) => {
setMemberships((prev) => [
{ playlist, hasDump: true },
...prev,
]);
setShowNewForm(false);
}}
onCancel={() => setShowNewForm(false)}
/>
)
: (
<button
type="button"
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
+ New playlist
</button>
)}
</div>
</div>
</div>,
document.body,
return (
<Modal title="Add to playlist" onClose={onClose}>
<PlaylistMembershipPanel
dumpId={dumpId}
memberships={memberships}
loading={loading}
onToggle={toggleMembership}
onPlaylistCreated={(membership) =>
setMemberships((prev) => [membership, ...prev])}
/>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { type ReactNode, useState } from "react";
import { Link, useNavigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
@@ -9,22 +9,11 @@ export function AppHeader(
) {
const { user } = useAuth();
const navigate = useNavigate();
const headerRef = useRef<HTMLElement>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
useEffect(() => {
// IntersectionObserver retained here to support a future floating action button
const el = headerRef.current;
if (!el) return;
const obs = new IntersectionObserver(() => {}, { threshold: 0 });
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<>
<header
ref={headerRef}
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
>
<Link to="/" state={{ tab: "hot" }} className="app-header-brand">
@@ -71,20 +60,6 @@ export function AppHeader(
</nav>
</header>
{
/* {user && createPortal(
<button
type="button"
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
onClick={() => setCreateModalOpen(true)}
aria-label="New dump"
>
+ New
</button>,
document.body,
)} */
}
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}

View File

@@ -1,8 +1,14 @@
import React, { useMemo, 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 type {
Comment,
CreateCommentRequest,
RawComment,
UpdateCommentRequest,
User,
} from "../model.ts";
import { deserializeComment, parseAPIResponse } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
@@ -69,7 +75,7 @@ function CommentNode({
const children = tree.get(comment.id) ?? [];
async function handleReply(e?: React.FormEvent) {
async function handleReply(e?: React.SubmitEvent) {
e?.preventDefault();
if (!replyBody.trim() || !token) return;
setSubmitting(true);
@@ -81,15 +87,20 @@ function CommentNode({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: replyBody, parentId: comment.id }),
body: JSON.stringify(
{
body: replyBody,
parentId: comment.id,
} satisfies CreateCommentRequest,
),
});
const data = await res.json();
const data = parseAPIResponse<RawComment>(await res.json());
if (data.success) {
onCommentCreated(deserializeComment(data.data as RawComment));
onCommentCreated(deserializeComment(data.data));
setReplyBody("");
setReplyOpen(false);
} else {
setReplyError(data.error?.message ?? "Failed to post reply.");
setReplyError(data.error.message);
}
} catch {
setReplyError("Could not reach the server. Please try again.");
@@ -109,7 +120,7 @@ function CommentNode({
}
}
async function handleEditSave(e?: React.FormEvent) {
async function handleEditSave(e?: React.SubmitEvent) {
e?.preventDefault();
if (!editBody.trim() || !token) return;
setEditSubmitting(true);
@@ -121,14 +132,14 @@ function CommentNode({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: editBody }),
body: JSON.stringify({ body: editBody } satisfies UpdateCommentRequest),
});
const data = await res.json();
const data = parseAPIResponse<RawComment>(await res.json());
if (data.success) {
onCommentUpdated(deserializeComment(data.data as RawComment));
onCommentUpdated(deserializeComment(data.data));
setEditOpen(false);
} else {
setEditError(data.error?.message ?? "Failed to save edit.");
setEditError(data.error.message);
}
} catch {
setEditError("Could not reach the server. Please try again.");
@@ -383,7 +394,7 @@ export function CommentThread({
const tree = useMemo(() => buildTree(comments), [comments]);
const roots = tree.get("root") ?? [];
async function handleTopLevelSubmit(e?: React.FormEvent) {
async function handleTopLevelSubmit(e?: React.SubmitEvent) {
e?.preventDefault();
if (!topLevelBody.trim() || !token) return;
setSubmitting(true);
@@ -395,14 +406,16 @@ export function CommentThread({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: topLevelBody }),
body: JSON.stringify(
{ body: topLevelBody } satisfies CreateCommentRequest,
),
});
const data = await res.json();
const data = parseAPIResponse<RawComment>(await res.json());
if (data.success) {
onCommentCreated(deserializeComment(data.data as RawComment));
onCommentCreated(deserializeComment(data.data));
setTopLevelBody("");
} else {
setTopLevelError(data.error?.message ?? "Failed to post comment.");
setTopLevelError(data.error.message);
}
} catch {
setTopLevelError("Could not reach the server. Please try again.");

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
@@ -10,19 +9,24 @@ import type {
RawDump,
RawPlaylistMembership,
} from "../model.ts";
import { deserializeDump, deserializePlaylistMembership } from "../model.ts";
import {
deserializeDump,
deserializePlaylistMembership,
parseAPIResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.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 { TextEditor } from "./TextEditor.tsx";
import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { MAX_FILE_SIZE } from "../config/upload.ts";
import { TextEditor } from "./TextEditor.tsx";
type Mode = "url" | "file";
type Phase = "create" | "playlist";
@@ -38,16 +42,10 @@ type UrlPreview =
| { status: "done"; richContent: RichContent | null };
function LocalFilePreview({ file }: { file: File }) {
const [src, setSrc] = useState<string | null>(null);
const src = useMemo(() => URL.createObjectURL(file), [file]);
const mime = file.type;
useEffect(() => {
const url = URL.createObjectURL(file);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return null;
useEffect(() => () => URL.revokeObjectURL(src), [src]);
if (mime.startsWith("image/")) {
return <img src={src} alt={file.name} className="local-preview-image" />;
@@ -58,7 +56,6 @@ function LocalFilePreview({ file }: { file: File }) {
if (mime.startsWith("audio/")) {
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
}
// For other types the drop zone chip already shows name + size.
return null;
}
@@ -69,7 +66,6 @@ interface DumpCreateModalProps {
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
const { authFetch } = useAuth();
const { injectDump } = useWS();
const backdropRef = useRef<HTMLDivElement>(null);
const [phase, setPhase] = useState<Phase>("create");
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
@@ -89,24 +85,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
// 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 (skip if a picker/dropdown already handled it)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
// Debounced URL preview
useEffect(() => {
@@ -172,7 +150,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
return () => globalThis.removeEventListener("paste", handler);
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setSubmitState({ status: "submitting" });
@@ -215,9 +193,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
});
}
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
const dump = deserializeDump(apiResponse.data as RawDump);
const dump = deserializeDump(apiResponse.data);
injectDump(dump);
setCreatedDump(dump);
setPhase("playlist");
@@ -238,7 +216,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
} else {
setSubmitState({
status: "error",
error: apiResponse.error?.message ?? "Failed to create dump.",
error: apiResponse.error.message,
});
}
} catch (err) {
@@ -274,255 +252,189 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
const submitting = submitState.status === "submitting";
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
return (
<Modal
title={phase === "create" ? "New dump" : "Add to playlist"}
onClose={onClose}
wide
>
<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>
{phase === "create"
? (
<>
<div className="visibility-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>
<div className="modal-body">
{phase === "create"
? (
<>
<div className="visibility-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" && (
<ErrorCard
title="Failed to post"
message={submitState.error}
/>
)}
<form onSubmit={handleSubmit} className="dump-form">
{submitState.status === "error" && (
<ErrorCard
title="Failed to post"
message={submitState.error}
/>
)}
{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}
/>
)}
</>
)
: (
<>
<FileDropZone
file={file}
onChange={setFile}
disabled={submitting}
/>
{file && <LocalFilePreview file={file} />}
</>
)}
<div className="form-group">
<label htmlFor="dc-comment">
Why are you dumping this?
</label>
<TextEditor
id="dc-comment"
value={comment}
onChange={setComment}
disabled={submitting}
placeholder="Tell the community what makes this worth their time..."
rows={3}
/>
</div>
<div className="visibility-toggle">
<button
type="button"
className={!isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(false)}
>
Public
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(true)}
>
Private
</button>
</div>
<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"
{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}
>
{submitting
? (mode === "url" ? "Fetching…" : "Uploading…")
: "Dump it"}
</button>
placeholder="https://..."
required
autoFocus
/>
</div>
</div>
</form>
</>
)
: (
<>
{createdDump && (
<p className="dump-create-success">
Dumped!{" "}
<Link to={dumpUrl(createdDump)} onClick={onClose}>
View dump
</Link>
</p>
{urlPreview.status === "loading" && (
<p className="preview-loading">Fetching preview</p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
<RichContentCard
richContent={urlPreview.richContent}
/>
)}
</>
)
: (
<>
<FileDropZone
file={file}
onChange={setFile}
disabled={submitting}
/>
{file && <LocalFilePreview file={file} />}
</>
)}
{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>
)}
<div className="form-group">
<label htmlFor="dc-comment">
Why are you dumping this?
</label>
<TextEditor
id="dc-comment"
value={comment}
onChange={setComment}
disabled={submitting}
placeholder="Tell the community what makes this worth their time..."
rows={3}
/>
</div>
{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="visibility-toggle">
<button
type="button"
className={!isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(false)}
>
Public
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(true)}
>
Private
</button>
</div>
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-primary"
onClick={onClose}
>
Done
</button>
</div>
<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={dumpUrl(createdDump)} onClick={onClose}>
View dump
</Link>
</p>
)}
</div>
</div>
</div>,
document.body,
<PlaylistMembershipPanel
dumpId={createdDump?.id ?? ""}
memberships={memberships}
loading={playlistsLoading}
onToggle={toggleMembership}
onPlaylistCreated={(membership) =>
setMemberships((prev) => [membership, ...prev])}
/>
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-primary"
onClick={onClose}
>
Done
</button>
</div>
</div>
</>
)}
</Modal>
);
}

56
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { type ReactNode, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
title: string;
onClose: () => void;
children: ReactNode;
wide?: boolean;
}
export function Modal({ title, onClose, children, wide = false }: ModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
>
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
<div className="modal-header">
<span className="modal-title">{title}</span>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import type { Playlist } from "../model.ts";
import { Modal } from "./Modal.tsx";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface NewPlaylistFormProps {
@@ -17,26 +17,6 @@ export function NewPlaylistForm(
}: NewPlaylistFormProps,
) {
const [open, setOpen] = useState(false);
const backdropRef = useRef<HTMLDivElement>(null);
const close = () => setOpen(false);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open]);
return (
<>
@@ -48,38 +28,16 @@ export function NewPlaylistForm(
{toggleLabel}
</button>
{open && createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) close();
}}
>
<div className="modal-card">
<div className="modal-header">
<span className="modal-title">New playlist</span>
<button
type="button"
className="modal-close-btn"
onClick={close}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
<PlaylistCreateForm
onCreated={(playlist) => {
onCreated(playlist);
close();
}}
onCancel={close}
/>
</div>
</div>
</div>,
document.body,
{open && (
<Modal title="New playlist" onClose={() => setOpen(false)}>
<PlaylistCreateForm
onCreated={(playlist) => {
onCreated(playlist);
setOpen(false);
}}
onCancel={() => setOpen(false)}
/>
</Modal>
)}
</>
);

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { TextEditor } from "./TextEditor.tsx";
@@ -23,7 +23,7 @@ export function PlaylistCreateForm(
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
if (!title.trim()) return;
setSubmitting(true);
@@ -32,15 +32,17 @@ export function PlaylistCreateForm(
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,
}),
body: JSON.stringify(
{
title: title.trim(),
description: description.trim() || undefined,
isPublic,
} satisfies CreatePlaylistRequest,
),
});
const body = await res.json();
const body = parseAPIResponse<RawPlaylist>(await res.json());
if (!body.success) {
setError(body.error?.message ?? "Failed to create playlist");
setError(body.error.message);
return;
}
const playlist = deserializePlaylist(body.data as RawPlaylist);

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import type { PlaylistMembership } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface PlaylistMembershipPanelProps {
dumpId: string;
memberships: PlaylistMembership[];
loading: boolean;
onToggle: (membership: PlaylistMembership) => void;
onPlaylistCreated: (membership: PlaylistMembership) => void;
}
export function PlaylistMembershipPanel({
dumpId,
memberships,
loading,
onToggle,
onPlaylistCreated,
}: PlaylistMembershipPanelProps) {
const [showNewForm, setShowNewForm] = useState(false);
return (
<>
{loading
? <p className="page-loading">Loading</p>
: memberships.length === 0 && !showNewForm
? <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={() => onToggle(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>
)}
{showNewForm
? (
<PlaylistCreateForm
dumpId={dumpId}
onCreated={(playlist) => {
onPlaylistCreated({ playlist, hasDump: true });
setShowNewForm(false);
}}
onCancel={() => setShowNewForm(false)}
/>
)
: (
<button
type="button"
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
+ New playlist
</button>
)}
</>
);
}

View File

@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import type { PublicUser } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
interface ProfileSubpageHeaderProps {
username: string;
profileUser: PublicUser;
title: string;
actions?: ReactNode;
}
export function ProfileSubpageHeader(
{ username, profileUser, title, actions }: ProfileSubpageHeaderProps,
) {
return (
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">{title}</h1>
{actions}
</div>
</div>
);
}