vibe coded v1
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { Link, useLocation, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import type { Dump } from "../model.ts";
|
||||
import type { Dump, PublicUser } from "../model.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { VoteButton } from "../components/VoteButton.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
type DumpState =
|
||||
| { status: "loading" }
|
||||
@@ -14,26 +21,44 @@ type DumpState =
|
||||
|
||||
export function Dump() {
|
||||
const { selectedDump } = useParams();
|
||||
const location = useLocation();
|
||||
const preloaded = (location.state as { dump?: Dump } | null)?.dump ?? null;
|
||||
|
||||
const [dumpState, setDumpState] = useState<DumpState>({ status: "loading" });
|
||||
const [dumpState, setDumpState] = useState<DumpState>(
|
||||
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
|
||||
);
|
||||
const [op, setOp] = useState<PublicUser | null>(null);
|
||||
|
||||
const { user } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
|
||||
// Fetch dump data
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
|
||||
if (preloaded) {
|
||||
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.success && setOp(r.data))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
setDumpState({ status: "loading" });
|
||||
setOp(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const apiResponse = await res.json();
|
||||
setDumpState({ status: "loaded", dump: apiResponse.data });
|
||||
const dump: Dump = apiResponse.data;
|
||||
setDumpState({ status: "loaded", dump });
|
||||
|
||||
fetch(`${API_URL}/api/users/by-id/${dump.userId}`)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.success && setOp(r.data))
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
setDumpState({
|
||||
status: "error",
|
||||
@@ -44,49 +69,82 @@ export function Dump() {
|
||||
}, [selectedDump]);
|
||||
|
||||
if (dumpState.status === "loading") {
|
||||
return <div className="loading">Loading dump...</div>;
|
||||
return <PageShell><p className="page-loading">Loading dump…</p></PageShell>;
|
||||
}
|
||||
|
||||
if (dumpState.status === "error") {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h2>Error</h2>
|
||||
<p>{dumpState.error}</p>
|
||||
<button type="button" onClick={() => globalThis.location.reload()}>
|
||||
Retry
|
||||
</button>
|
||||
<p>
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{dumpState.error}</p>
|
||||
<button type="button" onClick={() => globalThis.location.reload()}>Retry</button>
|
||||
<Link to="/">← Back to all dumps</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { dump } = dumpState;
|
||||
const canEdit = !!user &&
|
||||
(dump.userId === user.id || user.isAdmin === true);
|
||||
const canEdit = !!user && (dump.userId === user.id || user.isAdmin === true);
|
||||
|
||||
return (
|
||||
<div className="dump-container">
|
||||
<div className="dump-meta">
|
||||
<h1>{dump.title}</h1>
|
||||
{dump.description && (
|
||||
<p className="dump-description">{dump.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<PageShell>
|
||||
<div className="dump-detail">
|
||||
{/* Post header */}
|
||||
<div className="dump-post-header">
|
||||
<div className="dump-header-block">
|
||||
<VoteButton
|
||||
dumpId={dump.id}
|
||||
count={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
disabled={!user}
|
||||
onCast={castVote}
|
||||
onRemove={removeVote}
|
||||
/>
|
||||
<div className="dump-header-info">
|
||||
<h1 className="dump-title">{dump.title}</h1>
|
||||
<div className="dump-op">
|
||||
<Avatar
|
||||
userId={dump.userId}
|
||||
username={op?.username ?? "?"}
|
||||
hasAvatar={!!op?.avatarMime}
|
||||
size={22}
|
||||
/>
|
||||
{op
|
||||
? <Link to={`/users/${op.username}`} className="dump-op-link">{op.username}</Link>
|
||||
: <span className="dump-op-link">…</span>}
|
||||
<time className="dump-card-date" dateTime={dump.createdAt} title={new Date(dump.createdAt).toLocaleString()}>
|
||||
{relativeTime(dump.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dump-grid">
|
||||
~
|
||||
</div>
|
||||
{dump.comment && (
|
||||
<blockquote className="dump-comment">{dump.comment}</blockquote>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dump-actions">
|
||||
{canEdit && (
|
||||
<Link to={`/dumps/${dump.id}/edit`}>
|
||||
Edit dump
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/">← Back to all dumps</Link>
|
||||
{/* Main content */}
|
||||
<div className="dump-rich-content">
|
||||
{dump.kind === "file"
|
||||
? <FilePreview dump={dump} />
|
||||
: dump.richContent
|
||||
? <RichContentCard richContent={dump.richContent} />
|
||||
: (
|
||||
<a href={dump.url} target="_blank" rel="noopener noreferrer" className="dump-url-link">
|
||||
{dump.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="dump-actions">
|
||||
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
|
||||
<Link to="/">← Back to all dumps</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,139 @@
|
||||
import { SubmitEvent, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import type { CreateDumpRequest } from "../model.ts";
|
||||
|
||||
import type { CreateUrlDumpRequest, RichContent } from "../model.ts";
|
||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import { MediaPlayer } from "../components/MediaPlayer.tsx";
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
type Mode = "url" | "file";
|
||||
type DumpCreateState =
|
||||
| { 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 = URL.createObjectURL(file);
|
||||
const mime = file.type;
|
||||
|
||||
useEffect(() => () => URL.revokeObjectURL(src), [src]);
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return <MediaPlayer src={src} kind="video" mime={mime} />;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer src={src} kind="audio" />;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function DumpCreate() {
|
||||
const navigate = useNavigate();
|
||||
const { authFetch } = useRequiredAuth();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [mode, setMode] = useState<Mode>("url");
|
||||
const [url, setUrl] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [state, setState] = useState<DumpCreateState>({ status: "idle" });
|
||||
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" });
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
// Debounced URL preview fetch
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
|
||||
if (!trimmedTitle) {
|
||||
setState({ status: "error", error: "Title is required." });
|
||||
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;
|
||||
}
|
||||
|
||||
const body: CreateDumpRequest = {
|
||||
title,
|
||||
description: description || undefined,
|
||||
};
|
||||
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]);
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setState({ status: "submitting" });
|
||||
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res: Response;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
if (mode === "url") {
|
||||
if (!url.trim()) {
|
||||
setState({ status: "error", error: "URL is required." });
|
||||
return;
|
||||
}
|
||||
const body: CreateUrlDumpRequest = {
|
||||
url: url.trim(),
|
||||
comment: comment.trim() || undefined,
|
||||
};
|
||||
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
if (!file) {
|
||||
setState({ status: "error", error: "Please select a file." });
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setState({ 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());
|
||||
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
const apiResponse = await res.json();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const apiResponse = await res.json();
|
||||
if (apiResponse.success) {
|
||||
const createdDump = apiResponse.data;
|
||||
navigate(`/dumps/${createdDump.id}`);
|
||||
navigate(`/dumps/${apiResponse.data.id}`);
|
||||
} else {
|
||||
setState({
|
||||
status: "error",
|
||||
error: apiResponse.error.message,
|
||||
});
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
} catch (err) {
|
||||
setState({
|
||||
@@ -66,55 +143,143 @@ export function DumpCreate() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = state.status === "submitting";
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: ClipboardEvent) => {
|
||||
const pastedFile = e.clipboardData?.files[0];
|
||||
if (pastedFile) {
|
||||
setMode("file");
|
||||
setUrl("");
|
||||
setUrlPreview({ status: "idle" });
|
||||
setFile(pastedFile);
|
||||
setState({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Only intercept text pastes when outside an input/textarea
|
||||
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());
|
||||
setState({ status: "idle" });
|
||||
}
|
||||
} catch { /* not a URL */ }
|
||||
};
|
||||
|
||||
window.addEventListener("paste", handler);
|
||||
return () => window.removeEventListener("paste", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="dump-container">
|
||||
<div className="dump-meta">
|
||||
<h1>Create Dump</h1>
|
||||
</div>
|
||||
|
||||
{state.status === "error" && (
|
||||
<div className="error-banner">{state.error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="dump-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">
|
||||
<strong>Title</strong>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={state.status === "submitting"}
|
||||
required
|
||||
/>
|
||||
<PageShell centered>
|
||||
<div className="dump-create-wrapper">
|
||||
<div className="dump-create-header">
|
||||
<h1 className="dump-create-title">New dump</h1>
|
||||
<div className="dump-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "url" ? "active" : ""}
|
||||
onClick={() => { setMode("url"); setFile(null); setState({ status: "idle" }); }}
|
||||
disabled={submitting}
|
||||
>
|
||||
🔗 URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "file" ? "active" : ""}
|
||||
onClick={() => { setMode("file"); setUrl(""); setUrlPreview({ status: "idle" }); setState({ status: "idle" }); }}
|
||||
disabled={submitting}
|
||||
>
|
||||
📎 File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="dump-create-form dump-form">
|
||||
{state.status === "error" && (
|
||||
<p className="form-error">{state.error}</p>
|
||||
)}
|
||||
|
||||
{mode === "url"
|
||||
? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="url">URL</label>
|
||||
<input
|
||||
id="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);
|
||||
setState({ 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="file">File</label>
|
||||
<input
|
||||
id="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="description">
|
||||
<strong>Description (optional)</strong>
|
||||
</label>
|
||||
<label htmlFor="comment">Why are you dumping this?</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={state.status === "submitting"}
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder="Tell the community what makes this worth their time..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dump-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.status === "submitting"}
|
||||
>
|
||||
{state.status === "submitting" ? "Creating..." : "Create dump"}
|
||||
</button>
|
||||
|
||||
<Link to="/">Cancel</Link>
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<Link to="/" className="form-cancel">Cancel</Link>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? (mode === "url" ? "Fetching…" : "Uploading…") : "Dump it"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import type { Dump, UpdateDumpRequest } from "../model.ts";
|
||||
|
||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
|
||||
type DumpEditState =
|
||||
| { status: "loading" }
|
||||
@@ -18,8 +20,9 @@ export function DumpEdit() {
|
||||
const { authFetch } = useRequiredAuth();
|
||||
|
||||
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [newFile, setNewFile] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
@@ -28,21 +31,18 @@ export function DumpEdit() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`);
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const apiResponse = await res.json();
|
||||
|
||||
if (apiResponse.success) {
|
||||
const dump: Dump = apiResponse.data;
|
||||
setTitle(dump.title);
|
||||
setDescription(dump.description ?? "");
|
||||
setUrl(dump.url ?? "");
|
||||
setComment(dump.comment ?? "");
|
||||
setState({ status: "loaded", dump });
|
||||
} else {
|
||||
setState({
|
||||
status: "error",
|
||||
error: apiResponse.error.message,
|
||||
});
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
} catch (err) {
|
||||
setState({
|
||||
@@ -56,25 +56,41 @@ export function DumpEdit() {
|
||||
const handleSave = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
|
||||
const body: UpdateDumpRequest = {
|
||||
title,
|
||||
description: description || undefined,
|
||||
};
|
||||
let res: Response;
|
||||
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (state.dump.kind === "file" && newFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", newFile);
|
||||
if (comment.trim()) formData.append("comment", comment.trim());
|
||||
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}/file`, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
} else {
|
||||
const body: UpdateDumpRequest = state.dump.kind === "url"
|
||||
? { url: url.trim() || undefined, comment: comment.trim() || undefined }
|
||||
: { comment: comment.trim() || undefined };
|
||||
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: `Update failed (${res.status})`,
|
||||
});
|
||||
setState({ status: "error", error: `Update failed (${res.status})` });
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/dumps/${state.dump.id}`);
|
||||
const apiResponse = await res.json();
|
||||
if (!apiResponse.success) {
|
||||
setState({ status: "error", error: apiResponse.error?.message ?? "Update failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDump: Dump = apiResponse.data;
|
||||
setState({ status: "loaded", dump: updatedDump });
|
||||
setNewFile(null);
|
||||
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -85,85 +101,110 @@ export function DumpEdit() {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: `Delete failed (${res.status})`,
|
||||
});
|
||||
setState({ status: "error", error: `Delete failed (${res.status})` });
|
||||
return;
|
||||
}
|
||||
|
||||
navigate("/");
|
||||
navigate("/", { state: { deletedDumpId: state.dump.id } });
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <div className="loading">Loading dump...</div>;
|
||||
return <PageShell><p className="page-loading">Loading dump…</p></PageShell>;
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h2>Error</h2>
|
||||
<p>{state.error}</p>
|
||||
<button type="button" onClick={() => globalThis.location.reload()}>
|
||||
Retry
|
||||
</button>
|
||||
<p>
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{state.error}</p>
|
||||
<button type="button" onClick={() => globalThis.location.reload()}>Retry</button>
|
||||
<Link to="/">← Back to all dumps</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { dump } = state;
|
||||
|
||||
return (
|
||||
<div className="dump-container">
|
||||
<div className="dump-meta">
|
||||
<h1>Edit Dump</h1>
|
||||
<PageShell>
|
||||
<div className="form-page form-page--two-col">
|
||||
<div className="form-page-header">
|
||||
<p className="form-page-eyebrow">Editing</p>
|
||||
<h1 className="form-page-title">{dump.title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="dump-edit-preview">
|
||||
{dump.kind === "file"
|
||||
? <FilePreview dump={dump} />
|
||||
: dump.richContent
|
||||
? <RichContentCard richContent={dump.richContent} />
|
||||
: dump.url && (
|
||||
<a href={dump.url} target="_blank" rel="noopener noreferrer" className="dump-url-link">
|
||||
{dump.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="dump-form"
|
||||
onSubmit={(e) => { e.preventDefault(); handleSave(); }}
|
||||
>
|
||||
{dump.kind === "url"
|
||||
? (
|
||||
<div className="form-group">
|
||||
<label htmlFor="url">URL</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.currentTarget.value)}
|
||||
placeholder="https://..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="form-group">
|
||||
<p className="dump-file-notice">
|
||||
<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)}
|
||||
/>
|
||||
{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
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.currentTarget.value)}
|
||||
placeholder="Tell the community what makes this worth their time..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={handleDelete} className="btn-danger">
|
||||
Delete dump
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<Link to={`/dumps/${dump.id}`} className="form-cancel">Cancel</Link>
|
||||
<button type="submit" className="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="dump-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">
|
||||
<strong>Title</strong>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="description">
|
||||
<strong>Description (optional)</strong>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dump-actions">
|
||||
<button type="submit">Save</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
style={{ backgroundColor: "#a02b2b" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<Link to={`/dumps/${state.dump.id}`}>Cancel</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,122 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { type Dump } from "../model.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
|
||||
type DumpsState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; dumps: Dump[] };
|
||||
|
||||
type SortMode = "new" | "hot";
|
||||
|
||||
function hotScore(dump: Dump): number {
|
||||
const ageHours = (Date.now() - new Date(dump.createdAt).getTime()) / 3_600_000;
|
||||
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
|
||||
}
|
||||
|
||||
export function Index() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)?.deletedDumpId;
|
||||
|
||||
const handleCreateDump = () => {
|
||||
navigate("/dumps/new");
|
||||
};
|
||||
const { user } = useAuth();
|
||||
const { onlineUsers, voteCounts, myVotes, recentDumps, deletedDumpIds, castVote, removeVote } = useWS();
|
||||
|
||||
const handleRegister = () => {
|
||||
navigate("/register");
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/", { replace: true });
|
||||
};
|
||||
|
||||
const [dumpsState, setDumpsState] = useState<DumpsState>({
|
||||
status: "loading",
|
||||
});
|
||||
const [dumpsState, setDumpsState] = useState<DumpsState>({ status: "loading" });
|
||||
const [sort, setSort] = useState<SortMode>("hot");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/dumps/`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const apiResponse = await response.json();
|
||||
setDumpsState({ status: "loaded", dumps: apiResponse.data });
|
||||
const res = await fetch(`${API_URL}/api/dumps/`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
setDumpsState({ status: "loaded", dumps: body.data });
|
||||
} catch (err) {
|
||||
setDumpsState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load dumps",
|
||||
});
|
||||
setDumpsState({ status: "error", error: err instanceof Error ? err.message : "Failed to load" });
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (dumpsState.status === "loading") {
|
||||
return (
|
||||
<main id="content">
|
||||
<div className="loading">Loading dumps...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const loading = dumpsState.status === "loading";
|
||||
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
||||
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
|
||||
const restIds = new Set(dumps.map((d) => d.id));
|
||||
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
|
||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
||||
|
||||
if (dumpsState.status === "error") {
|
||||
return (
|
||||
<main id="content">
|
||||
<div className="error-container">
|
||||
<h2>Error</h2>
|
||||
<p>{dumpsState.error}</p>
|
||||
<button type="button" onClick={() => globalThis.location.reload()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const sortedDumps = [...combined].sort(
|
||||
sort === "hot"
|
||||
? (a, b) => hotScore(b) - hotScore(a)
|
||||
: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
const { dumps } = dumpsState;
|
||||
const presenceRow = (
|
||||
<div className="index-presence">
|
||||
{onlineUsers.map((u) => (
|
||||
<Link key={u.userId} to={`/users/${u.username}`} title={u.username} className="index-presence-avatar">
|
||||
<Avatar userId={u.userId} username={u.username} hasAvatar={u.hasAvatar} size={32} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const sortButtons = !loading && !error && combined.length > 0 && (
|
||||
<div className="feed-sort">
|
||||
<button className={`feed-sort-btn${sort === "hot" ? " active" : ""}`} onClick={() => setSort("hot")}>Hot</button>
|
||||
<button className={`feed-sort-btn${sort === "new" ? " active" : ""}`} onClick={() => setSort("new")}>New</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<main id="content">
|
||||
<h1>🚚 Dumps</h1>
|
||||
<div className="index-page">
|
||||
<AppHeader centerSlot={
|
||||
<div className="header-center-slot">
|
||||
{presenceRow}
|
||||
{sortButtons}
|
||||
</div>
|
||||
} />
|
||||
|
||||
<p>Welcome, {user?.username ?? "guest"}!</p>
|
||||
{/* Shown only on narrow viewports */}
|
||||
<div className="index-below-header">
|
||||
{sortButtons}
|
||||
{presenceRow}
|
||||
</div>
|
||||
|
||||
{user &&
|
||||
<button type="button" onClick={handleCreateDump}>New dump</button>}
|
||||
{loading && <p className="index-status">Loading…</p>}
|
||||
{error && <p className="index-status index-status--error">{error}</p>}
|
||||
|
||||
<p>Click on a dump below to participate.</p>
|
||||
{!loading && !error && combined.length === 0 && (
|
||||
<p className="index-status">No dumps yet. Be the first!</p>
|
||||
)}
|
||||
|
||||
{dumps.length === 0
|
||||
? <p className="empty-state">No dumps available yet.</p>
|
||||
: (
|
||||
<ul>
|
||||
{dumps.map((dump) => (
|
||||
<li key={dump.id}>
|
||||
<Link to={`/dumps/${dump.id}`} className="dump">
|
||||
{dump.title}
|
||||
</Link>
|
||||
</li>
|
||||
{!loading && !error && combined.length > 0 && (
|
||||
<>
|
||||
|
||||
<ul className="dump-feed">
|
||||
{sortedDumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{user
|
||||
? (
|
||||
<form>
|
||||
<button type="button" onClick={handleLogout}>Logout</button>
|
||||
</form>
|
||||
)
|
||||
: (
|
||||
<form>
|
||||
<button type="button" onClick={handleRegister}>Register</button>
|
||||
<button type="button" onClick={handleLogin}>Log in</button>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SubmitEvent, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
type UserLoginState =
|
||||
| { status: "idle" }
|
||||
@@ -32,9 +33,7 @@ export function UserLogin() {
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const apiResponse = await res.json();
|
||||
|
||||
@@ -42,10 +41,7 @@ export function UserLogin() {
|
||||
login(apiResponse.data);
|
||||
navigate("/");
|
||||
} else {
|
||||
setState({
|
||||
status: "error",
|
||||
error: apiResponse.error.message,
|
||||
});
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
} catch (err) {
|
||||
setState({
|
||||
@@ -56,33 +52,39 @@ export function UserLogin() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
{state.status === "error" && (
|
||||
<div className="error-banner">{state.error}</div>
|
||||
)}
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-card-title">Log in</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.status === "submitting"}
|
||||
>
|
||||
{state.status === "submitting" ? "Logging in..." : "Login"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<div className="error-banner">{state.error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={state.status === "submitting"}>
|
||||
{state.status === "submitting" ? "Logging in…" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-card-footer">
|
||||
No account? <Link to="/register">Register</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
|
||||
export function UserProfile() {
|
||||
const { user } = useRequiredAuth();
|
||||
|
||||
return (
|
||||
`Hello, ${user.username}!`
|
||||
);
|
||||
}
|
||||
328
src/pages/UserPublicProfile.tsx
Normal file
328
src/pages/UserPublicProfile.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { AuthResponse, Dump, PublicUser } from "../model.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
|
||||
type ProfileState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; user: PublicUser; dumps: Dump[]; votes: Dump[] };
|
||||
|
||||
export function UserPublicProfile() {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, login } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
|
||||
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [userRes, dumpsRes, votesRes] = await Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(`${API_URL}/api/users/${username}/dumps`),
|
||||
fetch(`${API_URL}/api/users/${username}/votes`),
|
||||
]);
|
||||
|
||||
if (!userRes.ok) {
|
||||
throw new Error(userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`);
|
||||
}
|
||||
|
||||
const [userBody, dumpsBody, votesBody] = await Promise.all([
|
||||
userRes.json(),
|
||||
dumpsRes.json(),
|
||||
votesRes.json(),
|
||||
]);
|
||||
|
||||
setState({
|
||||
status: "loaded",
|
||||
user: userBody.data,
|
||||
dumps: dumpsBody.success ? dumpsBody.data : [],
|
||||
votes: votesBody.success ? votesBody.data : [],
|
||||
});
|
||||
} catch (err) {
|
||||
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" });
|
||||
}
|
||||
})();
|
||||
}, [username]);
|
||||
|
||||
// Add newly-voted own dumps to the Upvoted list.
|
||||
// Removals are handled inside UpvotedDumpList (with fade animation).
|
||||
useEffect(() => {
|
||||
if (prevMyVotesRef.current === null) {
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
return;
|
||||
}
|
||||
const prev = prevMyVotesRef.current;
|
||||
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const voteIds = new Set(s.votes.map((d) => d.id));
|
||||
const toAdd = s.dumps.filter((d) => myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id));
|
||||
if (toAdd.length === 0) return s;
|
||||
return { ...s, votes: [...toAdd, ...s.votes] };
|
||||
});
|
||||
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
}, [myVotes]);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || state.status !== "loaded") return;
|
||||
|
||||
setAvatarError(null);
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await authFetch(`${API_URL}/api/avatars/me`, { method: "POST", body: formData });
|
||||
const body = await res.json() as { success: boolean; data?: AuthResponse["user"]; error?: { message: string } };
|
||||
|
||||
if (!res.ok || !body.success) {
|
||||
setAvatarError(body.error?.message ?? "Upload failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw && body.data) {
|
||||
login({ ...(JSON.parse(storedRaw) as AuthResponse), user: body.data });
|
||||
}
|
||||
|
||||
setState((prev) => prev.status === "loaded"
|
||||
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
|
||||
: prev);
|
||||
} catch {
|
||||
setAvatarError("Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <PageShell><p className="page-loading">Loading profile…</p></PageShell>;
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{state.error}</p>
|
||||
<Link to="/">← Back</Link>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { user: profileUser, dumps, votes } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar-wrapper">
|
||||
<Avatar
|
||||
userId={profileUser.id}
|
||||
username={profileUser.username}
|
||||
hasAvatar={!!profileUser.avatarMime}
|
||||
size={72}
|
||||
/>
|
||||
{isOwnProfile && (
|
||||
<label className="avatar-change-overlay" title="Change avatar">
|
||||
{uploading ? "…" : "✎"}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
onChange={handleAvatarUpload}
|
||||
disabled={uploading}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="profile-username">{profileUser.username}</h1>
|
||||
{avatarError && <p className="form-error">{avatarError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-columns">
|
||||
<DumpList
|
||||
title={`Dumps (${dumps.length})`}
|
||||
dumps={dumps}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
/>
|
||||
|
||||
<UpvotedDumpList
|
||||
title={`Upvoted (${votes.filter((d) => myVotes.has(d.id)).length})`}
|
||||
dumps={votes}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
|
||||
|
||||
function DumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="profile-section">
|
||||
<h2>{title}</h2>
|
||||
{dumps.length === 0
|
||||
? <p className="empty-state">Nothing here yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{dumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={canVote}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Upvoted list: fades items out when votes are removed ────────────────────
|
||||
|
||||
function UpvotedDumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
}) {
|
||||
// fading: items whose vote was just removed — dimmed during cooldown, then animating out
|
||||
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
|
||||
|
||||
// cancels: id → function that aborts the pending removal sequence
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
// prevVotes: null on first render (skip initial diff), then previous myVotes snapshot
|
||||
const prevVotes = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(() => () => { cancels.current.forEach((c) => c()); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
// First run: capture baseline without triggering any fades
|
||||
if (prevVotes.current === null) {
|
||||
prevVotes.current = new Set(myVotes);
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = prevVotes.current;
|
||||
|
||||
// Newly unvoted → start fade (idempotent: skip if already running)
|
||||
for (const id of prev) {
|
||||
if (!myVotes.has(id) && !cancels.current.has(id)) {
|
||||
let dead = false;
|
||||
// We update `kill` in-place so the cancel ref always points to the right cleanup
|
||||
let kill = () => {};
|
||||
kill = () => {
|
||||
dead = true;
|
||||
setFading((f) => { const n = { ...f }; delete n[id]; return n; });
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
||||
|
||||
const t1 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
||||
const t2 = setTimeout(() => { if (!dead) kill(); }, 350);
|
||||
kill = () => { dead = true; clearTimeout(t2); setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); };
|
||||
}, 2000);
|
||||
|
||||
// Override kill so cancelling before t1 fires clears t1
|
||||
const killT1 = kill;
|
||||
void killT1; // used below
|
||||
kill = () => { dead = true; clearTimeout(t1); setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); };
|
||||
cancels.current.set(id, () => kill());
|
||||
}
|
||||
}
|
||||
|
||||
// Newly re-voted while fading → cancel removal
|
||||
for (const id of myVotes) {
|
||||
if (!prev.has(id) && cancels.current.has(id)) {
|
||||
cancels.current.get(id)!();
|
||||
}
|
||||
}
|
||||
|
||||
prevVotes.current = new Set(myVotes);
|
||||
}, [myVotes]);
|
||||
|
||||
// Visible = currently voted OR within the fade-out animation window
|
||||
const visibleDumps = dumps.filter((d) => myVotes.has(d.id) || d.id in fading);
|
||||
|
||||
return (
|
||||
<section className="profile-section">
|
||||
<h2>{title}</h2>
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state">Nothing here yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{visibleDumps.map((dump) => {
|
||||
const phase = fading[dump.id];
|
||||
const extraCls = phase === "cooldown" ? "dump-card--fading"
|
||||
: phase === "dismissing" ? "dump-card--dismissing"
|
||||
: undefined;
|
||||
return (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={canVote}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={extraCls}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SubmitEvent, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
type UserRegisterState =
|
||||
| { status: "idle" }
|
||||
@@ -32,9 +33,7 @@ export function UserRegister() {
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const apiResponse = await res.json();
|
||||
|
||||
@@ -42,10 +41,7 @@ export function UserRegister() {
|
||||
login(apiResponse.data);
|
||||
navigate("/");
|
||||
} else {
|
||||
setState({
|
||||
status: "error",
|
||||
error: apiResponse.error.message,
|
||||
});
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
} catch (err) {
|
||||
setState({
|
||||
@@ -56,33 +52,39 @@ export function UserRegister() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="registration-container">
|
||||
{state.status === "error" && (
|
||||
<div className="error-banner">{state.error}</div>
|
||||
)}
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-card-title">Register</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="registration-form">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.status === "submitting"}
|
||||
>
|
||||
{state.status === "submitting" ? "Registering..." : "Register"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<div className="error-banner">{state.error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={state.status === "submitting"}>
|
||||
{state.status === "submitting" ? "Registering…" : "Register"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-card-footer">
|
||||
Already have an account? <Link to="/login">Log in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user