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

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