v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
65
src/components/FollowButton.tsx
Normal file
65
src/components/FollowButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
50
src/components/NotificationBell.tsx
Normal file
50
src/components/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}{" "}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function PlaylistCreateForm(
|
||||
<div className="form-actions-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
className="form-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
|
||||
Reference in New Issue
Block a user