v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

View File

@@ -2,10 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type {
PlaylistMembership,
RawPlaylistMembership,
} from "../model.ts";
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
import { deserializePlaylistMembership } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";

View File

@@ -1,14 +1,14 @@
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";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
import { NotificationBell } from "./NotificationBell.tsx";
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
const { user } = useAuth();
const navigate = useNavigate();
const headerRef = useRef<HTMLElement>(null);
const [showFab, setShowFab] = useState(false);
const [_showFab, setShowFab] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
useEffect(() => {
@@ -28,7 +28,9 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
ref={headerRef}
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
>
<Link to="/" className="app-header-brand">🚚 gerbeur</Link>
<Link to="/" state={{ tab: "hot" }} className="app-header-brand">
🚚 gerbeur
</Link>
{centerSlot && <div className="app-header-center">{centerSlot}</div>}
@@ -42,9 +44,13 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
>
{user.username}
</Link>
<Link to="/playlists" className="app-header-user">
<Link
to={`/users/${user.username}/playlists`}
className="app-header-user"
>
Playlists
</Link>
<NotificationBell />
<button
type="button"
className="btn-primary"
@@ -71,7 +77,8 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
</nav>
</header>
{/* {user && createPortal(
{
/* {user && createPortal(
<button
type="button"
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
@@ -81,7 +88,8 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
+ New
</button>,
document.body,
)} */}
)} */
}
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />

View File

@@ -105,7 +105,10 @@ function CommentNode({
<li className="comment-node">
<div className="comment-node-inner comment-node-inner--deleted">
<div className="comment-avatar comment-avatar--deleted">
<div className="comment-avatar-placeholder" style={{ width: 28, height: 28 }} />
<div
className="comment-avatar-placeholder"
style={{ width: 28, height: 28 }}
/>
</div>
<div className="comment-content">
<p className="comment-deleted-placeholder">[deleted]</p>
@@ -194,14 +197,14 @@ function CommentNode({
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
if (
e.key === "Enter" && (e.ctrlKey || e.metaKey)
) handleReply(e);
}}
placeholder="Write a reply…"
rows={3}
/>
{replyError && (
<p className="comment-form-error">{replyError}</p>
)}
{replyError && <p className="comment-form-error">{replyError}</p>}
<div className="comment-form-actions">
<button
type="submit"
@@ -229,9 +232,7 @@ function CommentNode({
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH
? { paddingLeft: 0 }
: undefined}
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
>
{children.map((child) => (
<CommentNode
@@ -305,13 +306,18 @@ export function CommentThread({
</h2>
{currentUser && (
<form className="comment-form comment-top-form" onSubmit={handleTopLevelSubmit}>
<form
className="comment-form comment-top-form"
onSubmit={handleTopLevelSubmit}
>
<textarea
className="comment-reply-textarea"
value={topLevelBody}
onChange={(e) => setTopLevelBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleTopLevelSubmit(e);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
handleTopLevelSubmit(e);
}
}}
placeholder="Add a comment…"
rows={3}

View File

@@ -1,6 +1,7 @@
import { Link, useNavigate } from "react-router";
import type { Dump } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import FilePreview from "./FilePreview.tsx";
import RichContentCard from "./RichContentCard.tsx";
import { VoteButton } from "./VoteButton.tsx";
@@ -22,12 +23,19 @@ export function DumpCard(
DumpCardProps,
) {
const navigate = useNavigate();
const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id);
function handleNavigate() {
markDumpVisited(dump.id);
navigate(`/dumps/${dump.id}`);
}
return (
<li className={`dump-card${className ? ` ${className}` : ""}`}>
<div
className="dump-card-inner"
onClick={() => navigate(`/dumps/${dump.id}`)}
onClick={handleNavigate}
>
<div
className="dump-card-preview"
@@ -44,12 +52,18 @@ export function DumpCard(
<Link
to={`/dumps/${dump.id}`}
className="dump-card-title"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
markDumpVisited(dump.id);
}}
>
{unread && <span className="unread-dot" aria-hidden="true" />}
{dump.title}
</Link>
{dump.comment && (
<Markdown className="dump-card-comment" inline>{dump.comment}</Markdown>
<Markdown className="dump-card-comment" inline>
{dump.comment}
</Markdown>
)}
<div className="dump-card-meta">
<time
@@ -61,7 +75,8 @@ export function DumpCard(
</time>
{dump.commentCount > 0 && (
<span className="dump-card-comment-count">
{dump.commentCount} {dump.commentCount === 1 ? "comment" : "comments"}
{dump.commentCount}{" "}
{dump.commentCount === 1 ? "comment" : "comments"}
</span>
)}
{dump.isPrivate && isOwner && (

View File

@@ -10,11 +10,9 @@ import type {
RawDump,
RawPlaylistMembership,
} from "../model.ts";
import {
deserializeDump,
deserializePlaylistMembership,
} from "../model.ts";
import { deserializeDump, deserializePlaylistMembership } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { formatBytes } from "../utils/format.ts";
import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx";
@@ -74,6 +72,7 @@ interface DumpCreateModalProps {
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
const { authFetch } = useAuth();
const { injectDump } = useWS();
const backdropRef = useRef<HTMLDivElement>(null);
const [phase, setPhase] = useState<Phase>("create");
@@ -225,6 +224,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
const apiResponse = await res.json();
if (apiResponse.success) {
const dump = deserializeDump(apiResponse.data as RawDump);
injectDump(dump);
setCreatedDump(dump);
setPhase("playlist");
setPlaylistsLoading(true);
@@ -281,7 +281,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
}
};
const submitting = submitState.status === "submitting";
return createPortal(
@@ -376,7 +375,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
<RichContentCard richContent={urlPreview.richContent} />
<RichContentCard
richContent={urlPreview.richContent}
/>
)}
</>
)
@@ -411,21 +412,24 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
/>
</div>
<label className="toggle-row">
<span className="toggle-label">Public</span>
<span className="toggle-switch">
<input
type="checkbox"
checked={!isPrivate}
onChange={(e) => setIsPrivate(!e.target.checked)}
disabled={submitting}
/>
<span className="toggle-thumb" />
</span>
{isPrivate && (
<span className="toggle-hint">Only visible to you</span>
)}
</label>
<div className="dump-mode-toggle">
<button
type="button"
className={!isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(false)}
>
Public
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(true)}
>
Private
</button>
</div>
<div className="form-actions">
<div className="form-actions-right">

View File

@@ -0,0 +1,65 @@
import { useAuth } from "../hooks/useAuth.ts";
import { useFollows } from "../hooks/useFollows.ts";
interface FollowUserButtonProps {
targetUserId: string;
targetUsername: string;
}
interface FollowPlaylistButtonProps {
targetPlaylistId: string;
isPublic: boolean;
}
export function FollowUserButton(
{ targetUserId, targetUsername }: FollowUserButtonProps,
) {
const { user } = useAuth();
const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows();
if (!user || user.id === targetUserId) return null;
const isFollowing = followedUserIds.has(targetUserId);
return (
<button
type="button"
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
disabled={!isLoaded}
onClick={() =>
isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
aria-label={isFollowing
? `Unfollow ${targetUsername}`
: `Follow ${targetUsername}`}
>
{isFollowing ? "Following" : "Follow"}
</button>
);
}
export function FollowPlaylistButton(
{ targetPlaylistId, isPublic }: FollowPlaylistButtonProps,
) {
const { user } = useAuth();
const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } =
useFollows();
if (!user || !isPublic) return null;
const isFollowing = followedPlaylistIds.has(targetPlaylistId);
return (
<button
type="button"
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
disabled={!isLoaded}
onClick={() =>
isFollowing
? unfollowPlaylist(targetPlaylistId)
: followPlaylist(targetPlaylistId)}
aria-label={isFollowing ? "Unfollow playlist" : "Follow playlist"}
>
{isFollowing ? "Following" : "Follow"}
</button>
);
}

View File

@@ -20,7 +20,10 @@ export function GlobalPlayer() {
document.body.classList.add("has-player");
const observer = new ResizeObserver(() => {
document.body.style.setProperty("--player-height", `${el.offsetHeight}px`);
document.body.style.setProperty(
"--player-height",
`${el.offsetHeight}px`,
);
});
observer.observe(el);
return () => {
@@ -37,13 +40,24 @@ export function GlobalPlayer() {
if (!current) return null;
return (
<div className={`global-player global-player--${current.type}${reduced ? " global-player--reduced" : ""}`} ref={ref}>
<div
className={`global-player global-player--${current.type}${
reduced ? " global-player--reduced" : ""
}`}
ref={ref}
>
<div className="global-player-header">
<span className="global-player-title">{current.title ?? current.embedUrl}</span>
<button className="btn btn--ghost" onClick={() => setReduced((r) => !r)}>
<span className="global-player-title">
{current.title ?? current.embedUrl}
</span>
<button
type="button"
className="btn btn--ghost"
onClick={() => setReduced((r) => !r)}
>
{reduced ? "▲" : "▼"}
</button>
<button className="btn btn--ghost" onClick={stop}>
<button type="button" className="btn btn--ghost" onClick={stop}>
</button>
</div>

View File

@@ -9,9 +9,15 @@ interface MarkdownProps {
const REMARK_PLUGINS = [remarkGfm];
export function Markdown({ children, className, inline = false }: MarkdownProps) {
export function Markdown(
{ children, className, inline = false }: MarkdownProps,
) {
return (
<div className={`md${className ? ` ${className}` : ""}${inline ? " md--inline" : ""}`}>
<div
className={`md${className ? ` ${className}` : ""}${
inline ? " md--inline" : ""
}`}
>
<ReactMarkdown
remarkPlugins={REMARK_PLUGINS}
components={{

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { useWS } from "../hooks/useWS.ts";
export function NotificationBell() {
const { unreadNotificationCount, lastNotification } = useWS();
const navigate = useNavigate();
const [ringing, setRinging] = useState(false);
const animatingRef = useRef(false);
// Initialised to the ID already in context — so the first effect run never
// animates, regardless of whether lastNotification is null or stale.
const lastSeenIdRef = useRef<string | null>(lastNotification?.id ?? null);
useEffect(() => {
if (!lastNotification) return;
if (lastNotification.id === lastSeenIdRef.current) return;
lastSeenIdRef.current = lastNotification.id;
if (animatingRef.current) return;
animatingRef.current = true;
setRinging(true);
const t = setTimeout(() => {
setRinging(false);
animatingRef.current = false;
}, 700);
return () => clearTimeout(t);
}, [lastNotification]);
return (
<button
type="button"
className={`notification-bell${
ringing ? " notification-bell--ringing" : ""
}`}
onClick={() => navigate("/notifications")}
aria-label={`Notifications${
unreadNotificationCount > 0
? ` (${unreadNotificationCount} unread)`
: ""
}`}
>
<span className="notification-bell-icon">🔔</span>
{unreadNotificationCount > 0 && (
<span className="notification-badge">
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
</span>
)}
</button>
);
}

View File

@@ -2,19 +2,35 @@ import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import {
isPlaylistVisited,
isRecent,
markPlaylistVisited,
} from "../utils/visited.ts";
interface PlaylistCardProps {
playlist: Playlist;
onDelete?: () => void;
isOwner?: boolean;
}
export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
export function PlaylistCard(
{ playlist, onDelete, isOwner }: PlaylistCardProps,
) {
const navigate = useNavigate();
const unread = !isOwner && isRecent(playlist.createdAt) &&
!isPlaylistVisited(playlist.id);
function handleNavigate() {
markPlaylistVisited(playlist.id);
navigate(`/playlists/${playlist.id}`);
}
return (
<li className="playlist-card">
<div
className="playlist-card-inner"
onClick={() => navigate(`/playlists/${playlist.id}`)}
onClick={handleNavigate}
>
<div className="playlist-card-preview">
{playlist.imageMime
@@ -31,8 +47,12 @@ export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
<Link
to={`/playlists/${playlist.id}`}
className="playlist-card-title"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
markPlaylistVisited(playlist.id);
}}
>
{unread && <span className="unread-dot" aria-hidden="true" />}
{playlist.title}
</Link>
{playlist.description && (
@@ -46,6 +66,15 @@ export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
>
{playlist.isPublic ? "public" : "private"}
</span>
{playlist.ownerUsername && !isOwner && (
<Link
to={`/users/${playlist.ownerUsername}`}
className="playlist-card-owner"
onClick={(e) => e.stopPropagation()}
>
@{playlist.ownerUsername}
</Link>
)}
{playlist.dumpCount !== undefined && (
<span className="playlist-card-count">
{playlist.dumpCount}{" "}

View File

@@ -93,7 +93,7 @@ export function PlaylistCreateForm(
<div className="form-actions-right">
<button
type="button"
className="btn-secondary"
className="form-cancel"
onClick={onCancel}
>
Cancel