v1 review pass: fixed some minor bugs
This commit is contained in:
@@ -1,32 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
|
||||
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const [showFab, setShowFab] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = headerRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => setShowFab(!entry.isIntersecting),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className={`app-header${centerSlot ? " app-header--has-center" : ""}`}>
|
||||
<Link to="/" className="app-header-brand">🚚 gerbeur</Link>
|
||||
<>
|
||||
<header
|
||||
ref={headerRef}
|
||||
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
|
||||
>
|
||||
<Link to="/" className="app-header-brand">🚚 gerbeur</Link>
|
||||
|
||||
{centerSlot && <div className="app-header-center">{centerSlot}</div>}
|
||||
{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>
|
||||
<nav className="app-header-nav">
|
||||
{user
|
||||
? (
|
||||
<>
|
||||
<Link
|
||||
to={`/users/${user.username}`}
|
||||
className="app-header-user"
|
||||
>
|
||||
{user.username}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => navigate("/dumps/new")}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<button type="button" onClick={() => navigate("/login")}>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => navigate("/register")}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{user && createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
|
||||
onClick={() => navigate("/dumps/new")}
|
||||
aria-label="New dump"
|
||||
>
|
||||
+ New
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ interface AvatarProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Avatar({ userId, username, hasAvatar, size = 36 }: AvatarProps) {
|
||||
export function Avatar(
|
||||
{ userId, username, hasAvatar, size = 36 }: AvatarProps,
|
||||
) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const sizeStyle = { width: size, height: size };
|
||||
|
||||
|
||||
@@ -15,12 +15,18 @@ interface DumpCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote, className }: DumpCardProps) {
|
||||
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-inner"
|
||||
onClick={() => navigate(`/dumps/${dump.id}`)}
|
||||
>
|
||||
<div
|
||||
className="dump-card-preview"
|
||||
onClick={dump.richContent ? (e) => e.stopPropagation() : undefined}
|
||||
@@ -33,11 +39,19 @@ export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote
|
||||
</div>
|
||||
|
||||
<div className="dump-card-body">
|
||||
<Link to={`/dumps/${dump.id}`} className="dump-card-title" onClick={(e) => e.stopPropagation()}>
|
||||
<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()}>
|
||||
<time
|
||||
className="dump-card-date"
|
||||
dateTime={dump.createdAt.toISOString()}
|
||||
title={dump.createdAt.toLocaleString()}
|
||||
>
|
||||
{relativeTime(dump.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,9 @@ function mimeIcon(mime: string): string {
|
||||
return "📁";
|
||||
}
|
||||
|
||||
export default function FilePreview({ dump, compact = false }: FilePreviewProps) {
|
||||
export default function FilePreview(
|
||||
{ dump, compact = false }: FilePreviewProps,
|
||||
) {
|
||||
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
|
||||
const mime = dump.fileMime ?? "";
|
||||
|
||||
|
||||
@@ -23,8 +23,12 @@ const IconPause = () => (
|
||||
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" />}
|
||||
? (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -55,7 +59,9 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current!;
|
||||
const onTime = () => { if (!dragging) setCurrent(a.currentTime); };
|
||||
const onTime = () => {
|
||||
if (!dragging) setCurrent(a.currentTime);
|
||||
};
|
||||
const onDuration = () => setDuration(a.duration);
|
||||
const onEnded = () => setPlaying(false);
|
||||
a.addEventListener("timeupdate", onTime);
|
||||
@@ -68,31 +74,52 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
// Show controls when paused; schedule hide when playing
|
||||
// Stop any in-flight load on unmount.
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current!;
|
||||
return () => {
|
||||
a.pause();
|
||||
a.removeAttribute("src");
|
||||
a.load();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Schedule controls hide when playing; controls are always visible when paused (derived below)
|
||||
useEffect(() => {
|
||||
if (kind !== "video") return;
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||
} else {
|
||||
setControlsVisible(true);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
}
|
||||
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [playing, kind]);
|
||||
|
||||
// Controls are always visible when paused or for audio; otherwise follow controlsVisible state
|
||||
const showingControls = kind !== "video" || !playing || controlsVisible;
|
||||
|
||||
const showControlsTemporarily = () => {
|
||||
if (kind !== "video") return;
|
||||
setControlsVisible(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
const a = mediaRef.current!;
|
||||
if (playing) { a.pause(); setPlaying(false); }
|
||||
else { a.play(); setPlaying(true); }
|
||||
if (playing) {
|
||||
a.pause();
|
||||
setPlaying(false);
|
||||
} else a.play().then(() => setPlaying(true)).catch(() => {});
|
||||
};
|
||||
|
||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -105,7 +132,10 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
mediaRef.current!.volume = v;
|
||||
if (v > 0 && muted) { setMuted(false); mediaRef.current!.muted = false; }
|
||||
if (v > 0 && muted) {
|
||||
setMuted(false);
|
||||
mediaRef.current!.muted = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
@@ -122,18 +152,29 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
|
||||
const controls = (
|
||||
<>
|
||||
<button type="button" className="audio-player-btn" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
|
||||
<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}%` }} />
|
||||
<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}
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
onMouseDown={() => setDragging(true)}
|
||||
onMouseUp={() => setDragging(false)}
|
||||
onChange={seek}
|
||||
@@ -144,15 +185,26 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
<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"}>
|
||||
<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}%` }} />
|
||||
<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}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
aria-label="Volume"
|
||||
/>
|
||||
@@ -160,7 +212,12 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
</div>
|
||||
|
||||
{kind === "video" && (
|
||||
<button type="button" className="audio-player-vol-btn" onClick={goFullscreen} aria-label="Fullscreen">
|
||||
<button
|
||||
type="button"
|
||||
className="audio-player-vol-btn"
|
||||
onClick={goFullscreen}
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
<IconFullscreen />
|
||||
</button>
|
||||
)}
|
||||
@@ -170,11 +227,12 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
if (kind === "video") {
|
||||
return (
|
||||
<div
|
||||
className={`video-player${controlsVisible ? " video-player--controls-visible" : ""}`}
|
||||
className={`video-player${
|
||||
showingControls ? " 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}
|
||||
@@ -193,7 +251,13 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
|
||||
return (
|
||||
<div className="audio-player">
|
||||
<audio ref={mediaRef as React.RefObject<HTMLAudioElement>} src={src} preload="metadata" />
|
||||
<audio
|
||||
ref={mediaRef as React.RefObject<HTMLAudioElement>}
|
||||
src={src}
|
||||
preload="metadata"
|
||||
>
|
||||
{mime && <source src={src} type={mime} />}
|
||||
</audio>
|
||||
{controls}
|
||||
</div>
|
||||
);
|
||||
|
||||
17
src/components/PageError.tsx
Normal file
17
src/components/PageError.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PageShell } from "./PageShell.tsx";
|
||||
|
||||
export function PageError({ message, actions }: {
|
||||
message: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{message}</p>
|
||||
{actions && <div className="page-error-actions">{actions}</div>}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,9 @@ export function PageShell({ children, centered = false }: PageShellProps) {
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<AppHeader />
|
||||
<main className={`page-content${centered ? " page-content--centered" : ""}`}>
|
||||
<main
|
||||
className={`page-content${centered ? " page-content--centered" : ""}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RichContent } from "../model";
|
||||
import type { RichContent } from "../model.ts";
|
||||
|
||||
interface RichContentCardProps {
|
||||
richContent: RichContent;
|
||||
|
||||
@@ -7,9 +7,12 @@ interface VoteButtonProps {
|
||||
onRemove: (dumpId: string) => void;
|
||||
}
|
||||
|
||||
export function VoteButton({ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps) {
|
||||
export function VoteButton(
|
||||
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
|
||||
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
|
||||
disabled={disabled}
|
||||
|
||||
Reference in New Issue
Block a user