vibe coded v1

This commit is contained in:
khannurien
2026-03-16 07:34:49 +00:00
parent 6207a7549f
commit e88fed4e98
48 changed files with 4303 additions and 595 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,19 @@ import { Dump } from "./pages/Dump.tsx";
import { DumpCreate } from "./pages/DumpCreate.tsx";
import { DumpEdit } from "./pages/DumpEdit.tsx";
import { UserLogin } from "./pages/UserLogin.tsx";
import { UserProfile } from "./pages/UserProfile.tsx";
import { UserPublicProfile } from "./pages/UserPublicProfile.tsx";
import { UserRegister } from "./pages/UserRegister.tsx";
import { AuthProvider } from "./contexts/AuthProvider.tsx";
import { WSProvider } from "./contexts/WSProvider.tsx";
import { useAuth } from "./hooks/useAuth.ts";
import "./App.css";
function App() {
function AppRoutes() {
const { token } = useAuth();
return (
<AuthProvider>
<WSProvider token={token}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
@@ -53,16 +56,17 @@ function App() {
</RestrictedGuest>
}
/>
<Route
path="/profile"
element={
<RestrictedLoggedIn>
<UserProfile />
</RestrictedLoggedIn>
}
/>
<Route path="/users/:username" element={<UserPublicProfile />} />
</Routes>
</BrowserRouter>
</WSProvider>
);
}
function App() {
return (
<AuthProvider>
<AppRoutes />
</AuthProvider>
);
}

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { Link, useNavigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
const { user } = useAuth();
const navigate = useNavigate();
return (
<header className={`app-header${centerSlot ? " app-header--has-center" : ""}`}>
<Link to="/" className="app-header-brand">🚚 gerbeur</Link>
{centerSlot && <div className="app-header-center">{centerSlot}</div>}
<nav className="app-header-nav">
{user ? (
<>
<Link to={`/users/${user.username}`} className="app-header-user">
{user.username}
</Link>
<button className="btn-primary" onClick={() => navigate("/dumps/new")}>+ New</button>
</>
) : (
<>
<button onClick={() => navigate("/login")}>Log in</button>
<button className="btn-primary" onClick={() => navigate("/register")}>Register</button>
</>
)}
</nav>
</header>
);
}

37
src/components/Avatar.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { API_URL } from "../config/api.ts";
interface AvatarProps {
userId: string;
username: string;
hasAvatar: boolean;
size?: number;
}
export function Avatar({ userId, username, hasAvatar, size = 36 }: AvatarProps) {
const [imgFailed, setImgFailed] = useState(false);
const sizeStyle = { width: size, height: size };
if (hasAvatar && !imgFailed) {
return (
<img
src={`${API_URL}/api/avatars/${userId}`}
alt={username}
title={username}
style={sizeStyle}
className="avatar-img"
onError={() => setImgFailed(true)}
/>
);
}
return (
<div
className="avatar-initials"
title={username}
style={{ ...sizeStyle, fontSize: size * 0.45 }}
>
{username.charAt(0).toUpperCase()}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { Link, useNavigate } from "react-router";
import type { Dump } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import FilePreview from "./FilePreview.tsx";
import RichContentCard from "./RichContentCard.tsx";
import { VoteButton } from "./VoteButton.tsx";
interface DumpCardProps {
dump: Dump;
voteCount: number;
voted: boolean;
canVote: boolean;
castVote: (id: string) => void;
removeVote: (id: string) => void;
className?: string;
}
export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote, className }: DumpCardProps) {
const navigate = useNavigate();
return (
<li className={`dump-card${className ? ` ${className}` : ""}`}>
<div className="dump-card-inner" onClick={() => navigate(`/dumps/${dump.id}`)}>
<div
className="dump-card-preview"
onClick={dump.richContent ? (e) => e.stopPropagation() : undefined}
>
{dump.kind === "file"
? <FilePreview dump={dump} compact />
: dump.richContent
? <RichContentCard richContent={dump.richContent} compact />
: <span className="dump-card-preview-icon">🔗</span>}
</div>
<div className="dump-card-body">
<Link to={`/dumps/${dump.id}`} className="dump-card-title" onClick={(e) => e.stopPropagation()}>
{dump.title}
</Link>
{dump.comment && <p className="dump-card-comment">{dump.comment}</p>}
<time className="dump-card-date" dateTime={dump.createdAt} title={new Date(dump.createdAt).toLocaleString()}>
{relativeTime(dump.createdAt)}
</time>
</div>
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
<VoteButton
dumpId={dump.id}
count={voteCount}
voted={voted}
disabled={!canVote}
onCast={castVote}
onRemove={removeVote}
/>
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,69 @@
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { formatBytes } from "../utils/format.ts";
import { MediaPlayer } from "./MediaPlayer.tsx";
interface FilePreviewProps {
dump: Dump;
compact?: boolean;
}
function mimeIcon(mime: string): string {
if (mime.startsWith("video/")) return "🎬";
if (mime.startsWith("audio/")) return "🎵";
if (mime === "application/pdf") return "📄";
if (mime.startsWith("text/")) return "📝";
return "📁";
}
export default function FilePreview({ dump, compact = false }: FilePreviewProps) {
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
const mime = dump.fileMime ?? "";
if (compact) {
if (mime.startsWith("image/")) {
return (
<img
src={fileUrl}
alt={dump.fileName}
className="rich-content-compact-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
);
}
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
}
if (mime.startsWith("image/")) {
return (
<img src={fileUrl} alt={dump.fileName} className="file-preview-image" />
);
}
if (mime.startsWith("video/")) {
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />;
}
if (mime.startsWith("audio/")) {
return <MediaPlayer src={fileUrl} kind="audio" />;
}
if (mime === "application/pdf") {
return (
<embed
src={fileUrl}
type="application/pdf"
className="file-preview-pdf"
/>
);
}
return (
<a href={fileUrl} download={dump.fileName} className="file-download-link">
{mimeIcon(mime)} Download {dump.fileName}
{dump.fileSize != null && ` (${formatBytes(dump.fileSize)})`}
</a>
);
}

View File

@@ -0,0 +1,200 @@
import { useEffect, useRef, useState } from "react";
function fmt(s: number): string {
if (!isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
}
const IconPlay = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
<polygon points="6,3 20,12 6,21" />
</svg>
);
const IconPause = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
<rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" />
</svg>
);
const IconVolume = ({ muted }: { muted: boolean }) => (
<svg viewBox="0 0 24 24" fill="currentColor">
{muted
? <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
: <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />}
</svg>
);
const IconFullscreen = () => (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
);
const HIDE_DELAY = 2500;
interface MediaPlayerProps {
src: string;
kind: "audio" | "video";
mime?: string;
}
export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
const mediaRef = useRef<HTMLMediaElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [dragging, setDragging] = useState(false);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const a = mediaRef.current!;
const onTime = () => { if (!dragging) setCurrent(a.currentTime); };
const onDuration = () => setDuration(a.duration);
const onEnded = () => setPlaying(false);
a.addEventListener("timeupdate", onTime);
a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEnded);
return () => {
a.removeEventListener("timeupdate", onTime);
a.removeEventListener("durationchange", onDuration);
a.removeEventListener("ended", onEnded);
};
}, [dragging]);
// Show controls when paused; schedule hide when playing
useEffect(() => {
if (kind !== "video") return;
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
} else {
setControlsVisible(true);
}
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
}, [playing, kind]);
const showControlsTemporarily = () => {
if (kind !== "video") return;
setControlsVisible(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
}
};
const toggle = () => {
const a = mediaRef.current!;
if (playing) { a.pause(); setPlaying(false); }
else { a.play(); setPlaying(true); }
};
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
setCurrent(v);
mediaRef.current!.currentTime = v;
};
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
setVolume(v);
mediaRef.current!.volume = v;
if (v > 0 && muted) { setMuted(false); mediaRef.current!.muted = false; }
};
const toggleMute = () => {
const next = !muted;
setMuted(next);
mediaRef.current!.muted = next;
};
const goFullscreen = () => {
(mediaRef.current as HTMLVideoElement).requestFullscreen?.();
};
const progress = duration > 0 ? current / duration : 0;
const controls = (
<>
<button type="button" className="audio-player-btn" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
{playing ? <IconPause /> : <IconPlay />}
</button>
<span className="audio-player-time">{fmt(current)}</span>
<div className="audio-player-track">
<div className="audio-player-fill" style={{ width: `${progress * 100}%` }} />
<input
type="range"
className="audio-player-range"
min={0} max={duration || 1} step={0.01} value={current}
onMouseDown={() => setDragging(true)}
onMouseUp={() => setDragging(false)}
onChange={seek}
aria-label="Seek"
/>
</div>
<span className="audio-player-time">{fmt(duration)}</span>
<div className="audio-player-volume">
<button type="button" className="audio-player-vol-btn" onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"}>
<IconVolume muted={muted} />
</button>
<div className="audio-player-track audio-player-track--volume">
<div className="audio-player-fill" style={{ width: `${(muted ? 0 : volume) * 100}%` }} />
<input
type="range"
className="audio-player-range"
min={0} max={1} step={0.01} value={muted ? 0 : volume}
onChange={changeVolume}
aria-label="Volume"
/>
</div>
</div>
{kind === "video" && (
<button type="button" className="audio-player-vol-btn" onClick={goFullscreen} aria-label="Fullscreen">
<IconFullscreen />
</button>
)}
</>
);
if (kind === "video") {
return (
<div
className={`video-player${controlsVisible ? " video-player--controls-visible" : ""}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => playing && setControlsVisible(false)}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={mediaRef as React.RefObject<HTMLVideoElement>}
src={src}
preload="metadata"
className="video-player-video"
onClick={toggle}
>
{mime && <source src={src} type={mime} />}
</video>
<div className="video-player-controls audio-player">
{controls}
</div>
</div>
);
}
return (
<div className="audio-player">
<audio ref={mediaRef as React.RefObject<HTMLAudioElement>} src={src} preload="metadata" />
{controls}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { type ReactNode } from "react";
import { AppHeader } from "./AppHeader.tsx";
interface PageShellProps {
children: ReactNode;
centered?: boolean;
}
export function PageShell({ children, centered = false }: PageShellProps) {
return (
<div className="page-shell">
<AppHeader />
<main className={`page-content${centered ? " page-content--centered" : ""}`}>
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import type { RichContent } from "../model";
interface RichContentCardProps {
richContent: RichContent;
compact?: boolean;
}
export default function RichContentCard(
{ richContent, compact = false }: RichContentCardProps,
) {
if (compact) {
return (
<a
href={richContent.url}
target="_blank"
rel="noopener noreferrer"
className="rich-content-compact"
onClick={(e) => e.stopPropagation()}
>
{richContent.thumbnailUrl
? (
<img
src={richContent.thumbnailUrl}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)
: <span className="rich-content-compact-icon">🔗</span>}
</a>
);
}
return (
<a
href={richContent.url}
target="_blank"
rel="noopener noreferrer"
className={`rich-content-card rich-content-card--${richContent.type}`}
>
{richContent.thumbnailUrl && (
<img
src={richContent.thumbnailUrl}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<div className="rich-content-body">
{richContent.siteName && (
<span className="rich-content-badge">{richContent.siteName}</span>
)}
{richContent.title && (
<p className="rich-content-title">{richContent.title}</p>
)}
{richContent.description && (
<p className="rich-content-description">{richContent.description}</p>
)}
<span className="rich-content-url">{richContent.url}</span>
</div>
</a>
);
}

View File

@@ -0,0 +1,22 @@
interface VoteButtonProps {
dumpId: string;
count: number;
voted: boolean;
disabled?: boolean;
onCast: (dumpId: string) => void;
onRemove: (dumpId: string) => void;
}
export function VoteButton({ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps) {
return (
<button
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
disabled={disabled}
aria-label={voted ? "Remove vote" : "Upvote"}
title={disabled ? "Log in to vote" : undefined}
>
{count}
</button>
);
}

View File

@@ -7,3 +7,4 @@ const serverHost = import.meta.env.VITE_SERVER_HOST || "localhost";
const serverPort = import.meta.env.VITE_SERVER_PORT || "8000";
export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`;
export const WS_URL = API_URL.replace(/^http/, "ws");

View File

@@ -1,4 +1,4 @@
import { ReactNode, useState } from "react";
import { useState, type ReactNode } from "react";
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";

22
src/contexts/WSContext.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createContext } from "react";
import type { Dump, OnlineUser } from "../model.ts";
export interface WSContextValue {
onlineUsers: OnlineUser[];
voteCounts: Record<string, number>;
myVotes: Set<string>;
recentDumps: Dump[];
deletedDumpIds: Set<string>;
castVote: (dumpId: string) => void;
removeVote: (dumpId: string) => void;
}
export const WSContext = createContext<WSContextValue>({
onlineUsers: [],
voteCounts: {},
myVotes: new Set(),
recentDumps: [],
deletedDumpIds: new Set(),
castVote: () => {},
removeVote: () => {},
});

200
src/contexts/WSProvider.tsx Normal file
View File

@@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { WSContext, type WSContextValue } from "./WSContext.ts";
import { WS_URL } from "../config/api.ts";
import type { Dump, OnlineUser } from "../model.ts";
interface WSProviderProps {
children: ReactNode;
token: string | null;
}
const MAX_BACKOFF = 30_000;
const ACK_TIMEOUT = 5_000;
export function WSProvider({ children, token }: WSProviderProps) {
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
// Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes);
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
const socketRef = useRef<WebSocket | null>(null);
// Tracks pending optimistic votes: dumpId → revert timeout ID
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
let closed = false;
let backoff = 500;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function connect() {
if (closed) return;
const url = `${WS_URL}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`;
const ws = new WebSocket(url);
socketRef.current = ws;
ws.onmessage = (event) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
switch (msg.type) {
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
case "welcome": {
backoff = 500; // reset backoff on successful connect
const users = msg.users as OnlineUser[];
const votes = msg.myVotes as string[];
setOnlineUsers(users);
setMyVotes(new Set(votes));
break;
}
case "presence_update":
setOnlineUsers(msg.users as OnlineUser[]);
break;
case "votes_update": {
const { dumpId, voteCount } = msg as { dumpId: string; voteCount: number };
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
break;
}
case "dump_created": {
const dump = msg.dump as Dump;
setRecentDumps((prev) => [dump, ...prev]);
break;
}
case "dump_deleted": {
const dumpId = msg.dumpId as string;
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
break;
}
case "vote_ack": {
const { dumpId, action, voteCount } = msg as {
dumpId: string;
action: "cast" | "remove";
voteCount: number;
};
// Clear pending revert timeout
const timeout = pendingRef.current.get(dumpId);
if (timeout !== undefined) {
clearTimeout(timeout);
pendingRef.current.delete(dumpId);
}
// Reconcile with authoritative count
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
// Confirm vote state
setMyVotes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(dumpId);
else next.delete(dumpId);
return next;
});
break;
}
case "error":
// On error, revert any pending optimistic update for the affected dump
// (the revert timeout will handle it)
break;
}
};
ws.onclose = () => {
if (closed) return;
reconnectTimer = setTimeout(() => {
backoff = Math.min(backoff * 2, MAX_BACKOFF);
connect();
}, backoff);
};
ws.onerror = () => {
// onclose will fire after onerror
};
}
connect();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
socketRef.current?.close();
socketRef.current = null;
// Clear all pending revert timeouts
for (const t of pendingRef.current.values()) clearTimeout(t);
pendingRef.current.clear();
};
}, [token]);
const castVote = useCallback((dumpId: string) => {
// Optimistic update
const prevCount = voteCountsRef.current[dumpId] ?? 0;
const prevVoted = myVotesRef.current.has(dumpId);
if (prevVoted) return; // already voted
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
// Schedule revert if no ack
const timeout = setTimeout(() => {
pendingRef.current.delete(dumpId);
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, timeout);
socketRef.current?.send(JSON.stringify({ type: "vote_cast", dumpId }));
}, []);
const removeVote = useCallback((dumpId: string) => {
// Optimistic update
const prevCount = voteCountsRef.current[dumpId] ?? 0;
const prevVoted = myVotesRef.current.has(dumpId);
if (!prevVoted) return; // not voted
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: Math.max(0, prevCount - 1) }));
// Schedule revert if no ack
const timeout = setTimeout(() => {
pendingRef.current.delete(dumpId);
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, timeout);
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
}, []);
const value: WSContextValue = {
onlineUsers,
voteCounts,
myVotes,
recentDumps,
deletedDumpIds,
castVote,
removeVote,
};
return (
<WSContext.Provider value={value}>
{children}
</WSContext.Provider>
);
}

View File

@@ -19,13 +19,15 @@ export const useAuth = () => {
const authFetch = async (input: RequestInfo, init: RequestInit = {}) => {
const token = authResponse?.token;
const isFormData = init.body instanceof FormData;
const res = await fetch(input, {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
// Let the browser set Content-Type for FormData (it includes the boundary)
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
});

6
src/hooks/useWS.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useContext } from "react";
import { WSContext } from "../contexts/WSContext.ts";
export function useWS() {
return useContext(WSContext);
}

View File

@@ -40,13 +40,18 @@
body {
margin: 0;
display: grid;
place-items: center;
min-height: 100vh;
background-color: var(--color-bg);
color: var(--color-text);
}
#root {
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
}
a {
font-weight: 500;
color: var(--color-link);

View File

@@ -2,12 +2,29 @@
* Backend
*/
export interface RichContent {
type: string;
url: string;
siteName?: string;
title?: string;
description?: string;
thumbnailUrl?: string;
videoId?: string;
}
export interface Dump {
id: string;
kind: "url" | "file";
title: string;
description?: string;
comment?: string;
userId: string;
createdAt: Date;
url?: string;
richContent?: RichContent;
fileName?: string;
fileMime?: string;
fileSize?: number;
voteCount: number;
}
/**
@@ -19,6 +36,16 @@ export interface User {
username: string;
isAdmin: boolean;
createdAt: Date;
avatarMime?: string;
}
// Public user profile (no passwordHash)
export interface PublicUser {
id: string;
username: string;
isAdmin: boolean;
createdAt: Date;
avatarMime?: string;
}
export interface LoginUserRequest {
@@ -46,14 +73,15 @@ export interface AuthResponse {
* API
*/
export enum APIErrorCode {
BAD_REQUEST = "BAD_REQUEST",
NOT_FOUND = "NOT_FOUND",
SERVER_ERROR = "SERVER_ERROR",
TIMEOUT = "TIMEOUT",
UNAUTHORIZED = "UNAUTHORIZED",
VALIDATION_ERROR = "VALIDATION_ERROR",
}
export const APIErrorCode = {
BAD_REQUEST: "BAD_REQUEST",
NOT_FOUND: "NOT_FOUND",
SERVER_ERROR: "SERVER_ERROR",
TIMEOUT: "TIMEOUT",
UNAUTHORIZED: "UNAUTHORIZED",
VALIDATION_ERROR: "VALIDATION_ERROR",
} as const;
export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode];
export interface APIError {
code: APIErrorCode;
@@ -78,30 +106,78 @@ export type APIResponse<T> = APISuccess<T> | APIFailure;
* Request DTOs
*/
export interface CreateDumpRequest {
title: string;
description?: string;
export interface CreateUrlDumpRequest {
url: string;
comment?: string;
}
export interface UpdateDumpRequest {
title?: string;
description?: string;
url?: string;
comment?: string;
}
export interface LoginUserRequest {
/**
* WebSockets
*/
export interface VoteCastMessage {
type: "vote_cast";
dumpId: string;
userId: string;
}
export interface VoteAckMessageFailure {
type: "vote_ack";
dumpId: string;
success: false;
error: APIError;
}
export interface VoteAckMessageSuccess {
type: "vote_ack";
dumpId: string;
action: "cast" | "remove";
success: true;
voteCount: number;
error?: never;
}
export type VoteAckMessage = VoteAckMessageSuccess | VoteAckMessageFailure;
export interface VoteRemoveMessage {
type: "vote_remove";
dumpId: string;
}
export interface VotesUpdateMessage {
type: "votes_update";
dumpId: string;
voteCount: number;
}
export interface OnlineUser {
userId: string;
username: string;
password: string;
hasAvatar: boolean;
}
export interface RegisterUserRequest {
username: string;
password: string;
export interface WelcomeMessage {
type: "welcome";
users: OnlineUser[];
myVotes: string[];
}
export interface UpdateUserRequest {
username?: string;
password?: string;
isAdmin?: boolean;
export interface PresenceUpdateMessage {
type: "presence_update";
users: OnlineUser[];
}
export interface PingMessage {
type: "ping";
}
export interface PongMessage {
type: "pong";
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
import { useRequiredAuth } from "../hooks/useAuth.ts";
export function UserProfile() {
const { user } = useRequiredAuth();
return (
`Hello, ${user.username}!`
);
}

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

View File

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

5
src/utils/format.ts Normal file
View File

@@ -0,0 +1,5 @@
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

14
src/utils/relativeTime.ts Normal file
View File

@@ -0,0 +1,14 @@
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
export function relativeTime(dateStr: string): string {
const diff = new Date(dateStr).getTime() - Date.now(); // negative = past
const abs = Math.abs(diff) / 1000;
if (abs < 60) return rtf.format(-Math.round(abs), "second");
if (abs < 3600) return rtf.format(-Math.round(abs / 60), "minute");
if (abs < 86400) return rtf.format(-Math.round(abs / 3600), "hour");
if (abs < 7 * 86400) return rtf.format(-Math.round(abs / 86400), "day");
if (abs < 30 * 86400) return rtf.format(-Math.round(abs / 7 / 86400), "week");
if (abs < 365 * 86400) return rtf.format(-Math.round(abs / 30 / 86400), "month");
return rtf.format(-Math.round(abs / 365 / 86400), "year");
}