v3: localization fixes, char counters & limits on all text fields, ux fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
@@ -9,12 +9,16 @@ import { NotificationBell } from "./NotificationBell.tsx";
|
||||
import { UserMenu } from "./UserMenu.tsx";
|
||||
|
||||
export function AppHeader(
|
||||
{ centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean },
|
||||
{ centerSlot, disableNew, initialDumpUrl }: {
|
||||
centerSlot?: ReactNode;
|
||||
disableNew?: boolean;
|
||||
initialDumpUrl?: string;
|
||||
},
|
||||
) {
|
||||
const { user } = useAuth();
|
||||
const { wsStatus, wsErrorMessage } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,13 +80,18 @@ export function AppHeader(
|
||||
|
||||
{wsStatus === "disconnected" && wsErrorMessage && (
|
||||
<div className="app-header-status" role="alert">
|
||||
<strong><Trans>Live updates unavailable.</Trans></strong>{" "}
|
||||
<strong>
|
||||
<Trans>Live updates unavailable.</Trans>
|
||||
</strong>{" "}
|
||||
{wsErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createModalOpen && (
|
||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||
<DumpCreateModal
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
initialUrl={initialDumpUrl}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import type {
|
||||
Comment,
|
||||
CreateCommentRequest,
|
||||
@@ -79,7 +79,10 @@ function CommentNode({
|
||||
|
||||
async function handleReply(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (!replyBody.trim() || !token) return;
|
||||
if (
|
||||
!replyBody.trim() || !token ||
|
||||
replyBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) return;
|
||||
setSubmitting(true);
|
||||
setReplyError(null);
|
||||
try {
|
||||
@@ -124,7 +127,10 @@ function CommentNode({
|
||||
|
||||
async function handleEditSave(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (!editBody.trim() || !token) return;
|
||||
if (
|
||||
!editBody.trim() || !token ||
|
||||
editBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) return;
|
||||
setEditSubmitting(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
@@ -244,17 +250,24 @@ function CommentNode({
|
||||
onSubmit={handleEditSave}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{editError && (
|
||||
<ErrorCard title={t`Failed to save edit`} message={editError} />
|
||||
<ErrorCard
|
||||
title={t`Failed to save edit`}
|
||||
message={editError}
|
||||
/>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={editSubmitting || !editBody.trim()}
|
||||
disabled={editSubmitting || !editBody.trim() ||
|
||||
editBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{editSubmitting ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||
{editSubmitting
|
||||
? <Trans>Saving…</Trans>
|
||||
: <Trans>Save</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -329,17 +342,24 @@ function CommentNode({
|
||||
placeholder={t`Write a reply…`}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{replyError && (
|
||||
<ErrorCard title={t`Failed to post reply`} message={replyError} />
|
||||
<ErrorCard
|
||||
title={t`Failed to post reply`}
|
||||
message={replyError}
|
||||
/>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={submitting || !replyBody.trim()}
|
||||
disabled={submitting || !replyBody.trim() ||
|
||||
replyBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{submitting ? <Trans>Posting…</Trans> : <Trans>Post reply</Trans>}
|
||||
{submitting
|
||||
? <Trans>Posting…</Trans>
|
||||
: <Trans>Post reply</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -400,7 +420,10 @@ export function CommentThread({
|
||||
|
||||
async function handleTopLevelSubmit(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (!topLevelBody.trim() || !token) return;
|
||||
if (
|
||||
!topLevelBody.trim() || !token ||
|
||||
topLevelBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) return;
|
||||
setSubmitting(true);
|
||||
setTopLevelError(null);
|
||||
try {
|
||||
@@ -456,6 +479,7 @@ export function CommentThread({
|
||||
placeholder={t`Add a comment…`}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{topLevelError && (
|
||||
<ErrorCard
|
||||
@@ -467,9 +491,12 @@ export function CommentThread({
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={submitting || !topLevelBody.trim()}
|
||||
disabled={submitting || !topLevelBody.trim() ||
|
||||
topLevelBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{submitting ? <Trans>Posting…</Trans> : <Trans>Post comment</Trans>}
|
||||
{submitting
|
||||
? <Trans>Posting…</Trans>
|
||||
: <Trans>Post comment</Trans>}
|
||||
</button>
|
||||
{topLevelBody.trim() && (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
|
||||
22
src/components/CountedInput.tsx
Normal file
22
src/components/CountedInput.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type React from "react";
|
||||
import { charCountClass } from "../utils/charCount.ts";
|
||||
|
||||
interface CountedInputProps
|
||||
extends
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> {
|
||||
value: string;
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
export function CountedInput({ value, maxLength, ...rest }: CountedInputProps) {
|
||||
const countClass = charCountClass(value.length, maxLength);
|
||||
|
||||
return (
|
||||
<div className="input-with-count">
|
||||
<input value={value} maxLength={maxLength} {...rest} />
|
||||
<span className={`text-editor-count${countClass}`}>
|
||||
{value.length} / {maxLength}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import type {
|
||||
CreateUrlDumpRequest,
|
||||
Dump,
|
||||
@@ -51,16 +51,20 @@ type UrlPreview =
|
||||
| { status: "done"; richContent: RichContent | null };
|
||||
|
||||
function LocalFilePreview({ file }: { file: File }) {
|
||||
// useRef instead of useMemo+useEffect: StrictMode double-invokes effect
|
||||
// cleanups, which would revoke the blob URL before the video element can use it.
|
||||
const blobRef = useRef<{ file: File; url: string } | null>(null);
|
||||
if (blobRef.current?.file !== file) {
|
||||
if (blobRef.current) URL.revokeObjectURL(blobRef.current.url);
|
||||
blobRef.current = { file, url: URL.createObjectURL(file) };
|
||||
}
|
||||
const src = blobRef.current.url;
|
||||
const mime = file.type;
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSrc(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
if (!src) return null;
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
@@ -75,9 +79,12 @@ function LocalFilePreview({ file }: { file: File }) {
|
||||
|
||||
interface DumpCreateModalProps {
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
export function DumpCreateModal(
|
||||
{ onClose, initialUrl = "" }: DumpCreateModalProps,
|
||||
) {
|
||||
const { authFetch } = useAuth();
|
||||
const { injectDump } = useWS();
|
||||
|
||||
@@ -86,7 +93,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
|
||||
// Create phase state
|
||||
const [mode, setMode] = useState<Mode>("url");
|
||||
const [url, setUrl] = useState("");
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
@@ -166,6 +173,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (comment.length > VALIDATION.DUMP_COMMENT_MAX) return;
|
||||
setSubmitState({ status: "submitting" });
|
||||
|
||||
try {
|
||||
@@ -320,7 +328,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="dc-url"><Trans>URL</Trans></label>
|
||||
<label htmlFor="dc-url">
|
||||
<Trans>URL</Trans>
|
||||
</label>
|
||||
<input
|
||||
id="dc-url"
|
||||
type="url"
|
||||
@@ -345,7 +355,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
/>
|
||||
</div>
|
||||
{urlPreview.status === "loading" && (
|
||||
<p className="preview-loading"><Trans>Fetching preview…</Trans></p>
|
||||
<p className="preview-loading">
|
||||
<Trans>Fetching preview…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{urlPreview.status === "done" &&
|
||||
urlPreview.richContent && (
|
||||
@@ -377,6 +389,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
disabled={submitting}
|
||||
placeholder={t`Tell the community what makes this worth their time...`}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -411,7 +424,8 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting}
|
||||
disabled={submitting ||
|
||||
comment.length > VALIDATION.DUMP_COMMENT_MAX}
|
||||
>
|
||||
{submitting
|
||||
? (mode === "url"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
|
||||
@@ -136,9 +136,15 @@ export function FileDropZone({
|
||||
</svg>
|
||||
<p className="fdz__hint">{resolvedHint}</p>
|
||||
<p className="fdz__browse">
|
||||
<Trans>or <span className="fdz__browse-link">browse files</span></Trans>
|
||||
<Trans>
|
||||
or <span className="fdz__browse-link">browse files</span>
|
||||
</Trans>
|
||||
</p>
|
||||
{showLimit && <p className="fdz__limit"><Trans>Max 50 MB</Trans></p>}
|
||||
{showLimit && (
|
||||
<p className="fdz__limit">
|
||||
<Trans>Max 50 MB</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,9 +33,13 @@ function AudioFilePreview(
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(fileUrl, NUM_BARS)
|
||||
.then((p) => { if (!cancelled) setPeaks(p); })
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileUrl]);
|
||||
|
||||
const handlePlayBtn = () => {
|
||||
@@ -45,7 +49,10 @@ function AudioFilePreview(
|
||||
|
||||
const handleWaveformClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
if (isActive) {
|
||||
seekTo(ratio * duration);
|
||||
} else {
|
||||
@@ -58,7 +65,11 @@ function AudioFilePreview(
|
||||
const isPlaying = isActive && playing;
|
||||
|
||||
return (
|
||||
<div className={`audio-file-preview${isActive ? " audio-file-preview--active" : ""}`}>
|
||||
<div
|
||||
className={`audio-file-preview${
|
||||
isActive ? " audio-file-preview--active" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="audio-player-btn"
|
||||
@@ -67,13 +78,21 @@ function AudioFilePreview(
|
||||
>
|
||||
{isPlaying
|
||||
? (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<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>
|
||||
)
|
||||
: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{ marginLeft: "2px" }}
|
||||
>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
)}
|
||||
@@ -98,7 +117,9 @@ function AudioFilePreview(
|
||||
y={y}
|
||||
width={BAR_W}
|
||||
height={barH}
|
||||
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||
className={`waveform-bar${
|
||||
played ? " waveform-bar--played" : ""
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -130,7 +151,6 @@ export default function FilePreview(
|
||||
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
||||
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
|
||||
const mime = dump.fileMime ?? "";
|
||||
const isMedia = mime.startsWith("video/") || mime.startsWith("audio/");
|
||||
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||
|
||||
if (compact) {
|
||||
@@ -150,7 +170,9 @@ export default function FilePreview(
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isPlaying ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -174,14 +196,16 @@ export default function FilePreview(
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-compact-icon rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isPlaying ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
|
||||
}}
|
||||
>
|
||||
{mimeIcon(mime)}
|
||||
<span className="rich-content-compact-icon">{mimeIcon(mime)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -202,7 +226,13 @@ export default function FilePreview(
|
||||
<button
|
||||
type="button"
|
||||
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
|
||||
onClick={() => videoActive ? togglePlay() : play({ kind: "file", fileUrl, mimeType: mime, title: dump.title })}
|
||||
onClick={() =>
|
||||
videoActive ? togglePlay() : play({
|
||||
kind: "file",
|
||||
fileUrl,
|
||||
mimeType: mime,
|
||||
title: dump.title,
|
||||
})}
|
||||
>
|
||||
<video
|
||||
src={fileUrl}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useFollows } from "../hooks/useFollows.ts";
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
|
||||
function itemKey(item: { kind: string; embedUrl?: string; fileUrl?: string } | null) {
|
||||
function itemKey(
|
||||
item: { kind: string; embedUrl?: string; fileUrl?: string } | null,
|
||||
) {
|
||||
if (!item) return null;
|
||||
return item.kind === "embed" ? item.embedUrl : item.fileUrl;
|
||||
}
|
||||
@@ -50,13 +52,18 @@ export function GlobalPlayer() {
|
||||
|
||||
const typeClass = current.kind === "embed"
|
||||
? current.type
|
||||
: current.mimeType.startsWith("video/") ? "file-video" : "file-audio";
|
||||
: current.mimeType.startsWith("video/")
|
||||
? "file-video"
|
||||
: "file-audio";
|
||||
|
||||
const title = current.title ?? (current.kind === "embed" ? current.embedUrl : current.fileUrl);
|
||||
const title = current.title ??
|
||||
(current.kind === "embed" ? current.embedUrl : current.fileUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`global-player global-player--${typeClass}${reduced ? " global-player--reduced" : ""}`}
|
||||
className={`global-player global-player--${typeClass}${
|
||||
reduced ? " global-player--reduced" : ""
|
||||
}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="global-player-header">
|
||||
|
||||
@@ -108,6 +108,7 @@ export function JournalCard(
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl,
|
||||
title: dump.richContent?.title,
|
||||
type: dump.richContent?.type ?? "unknown",
|
||||
|
||||
@@ -61,16 +61,23 @@ function Waveform(
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(src, NUM_BARS)
|
||||
.then((p) => { if (!cancelled) setPeaks(p); })
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(ratio * duration);
|
||||
};
|
||||
|
||||
@@ -128,8 +135,16 @@ interface MediaPlayerProps {
|
||||
}
|
||||
|
||||
export function MediaPlayer(
|
||||
{ src, kind, mime, autoplay, onPlayStateChange, onTimeUpdate, seekRef, toggleRef }:
|
||||
MediaPlayerProps,
|
||||
{
|
||||
src,
|
||||
kind,
|
||||
mime,
|
||||
autoplay,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
seekRef,
|
||||
toggleRef,
|
||||
}: MediaPlayerProps,
|
||||
) {
|
||||
const mediaRef = useRef<HTMLMediaElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -141,36 +156,50 @@ export function MediaPlayer(
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Callback refs (mutated in render, never trigger effects as deps) ─────────
|
||||
// Updating refs in render is safe: they're only read in event handlers / effects,
|
||||
// never during render. This avoids the stale-closure problem without extra effects.
|
||||
// ── Callback refs ─────────────────────────────────────────────────────────────
|
||||
// Updated via effects (after render) so the linter doesn't flag render-phase ref
|
||||
// mutation. No cleanup → no null window. Negligible stale-window between commit
|
||||
// and effect, acceptable since these are only called from async event handlers.
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
onPlayStateChangeRef.current = onPlayStateChange;
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
|
||||
// Stable function refs — logic updated in render, registered once via a stable lambda.
|
||||
// This avoids the "no-deps effect" anti-pattern that created brief null windows on
|
||||
// every re-render (timeupdate fires 4×/s → ref nulled & re-registered each time).
|
||||
// Sync prop callbacks after every render
|
||||
useEffect(() => {
|
||||
onPlayStateChangeRef.current = onPlayStateChange;
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
});
|
||||
|
||||
// Stable function refs — updated via effects, indirected by the registration
|
||||
// effect below so external seekRef/toggleRef never see a null window.
|
||||
const seekToFnRef = useRef((_t: number) => {});
|
||||
seekToFnRef.current = (time: number) => {
|
||||
setCurrent(time);
|
||||
mediaRef.current!.currentTime = time;
|
||||
};
|
||||
|
||||
const toggleFnRef = useRef(() => {});
|
||||
toggleFnRef.current = () => {
|
||||
const a = mediaRef.current!;
|
||||
if (playing) {
|
||||
a.pause();
|
||||
setPlaying(false);
|
||||
onPlayStateChangeRef.current?.(false);
|
||||
} else {
|
||||
a.play()
|
||||
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// seekTo: all captured values are stable (setCurrent from useState, mediaRef DOM ref)
|
||||
useEffect(() => {
|
||||
seekToFnRef.current = (time: number) => {
|
||||
setCurrent(time);
|
||||
mediaRef.current!.currentTime = time;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// toggle: captures `playing` — re-synced whenever play state changes
|
||||
useEffect(() => {
|
||||
toggleFnRef.current = () => {
|
||||
const a = mediaRef.current!;
|
||||
if (playing) {
|
||||
a.pause();
|
||||
setPlaying(false);
|
||||
onPlayStateChangeRef.current?.(false);
|
||||
} else {
|
||||
a.play()
|
||||
.then(() => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
// Stable wrappers used everywhere inside the component
|
||||
const seekTo = (time: number) => seekToFnRef.current(time);
|
||||
@@ -182,10 +211,12 @@ export function MediaPlayer(
|
||||
useEffect(() => {
|
||||
if (!autoplay) return;
|
||||
mediaRef.current?.play()
|
||||
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
|
||||
.then(() => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [autoplay]);
|
||||
|
||||
// On unmount: pause and cut callbacks so stale timeupdate/ended events that fire
|
||||
// between React's commit and the listener-removal effect can't reach the provider.
|
||||
@@ -196,7 +227,6 @@ export function MediaPlayer(
|
||||
onPlayStateChangeRef.current = undefined;
|
||||
onTimeUpdateRef.current = undefined;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Register imperative handles into provider refs. seekRef/toggleRef are stable
|
||||
@@ -208,7 +238,6 @@ export function MediaPlayer(
|
||||
if (seekRef) seekRef.current = null;
|
||||
if (toggleRef) toggleRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seekRef, toggleRef]);
|
||||
|
||||
// Media element event listeners
|
||||
@@ -243,9 +272,14 @@ export function MediaPlayer(
|
||||
if (kind !== "video") return;
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
}
|
||||
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [playing, kind]);
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||
@@ -257,11 +291,15 @@ export function MediaPlayer(
|
||||
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 seek = (e: React.ChangeEvent<HTMLInputElement>) => seekTo(Number(e.target.value));
|
||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
seekTo(Number(e.target.value));
|
||||
|
||||
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
@@ -286,10 +324,20 @@ export function MediaPlayer(
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const track = kind === "audio"
|
||||
? <Waveform src={src} current={current} duration={duration} onSeek={seekTo} />
|
||||
? (
|
||||
<Waveform
|
||||
src={src}
|
||||
current={current}
|
||||
duration={duration}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<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"
|
||||
@@ -365,7 +413,9 @@ export function MediaPlayer(
|
||||
if (kind === "video") {
|
||||
return (
|
||||
<div
|
||||
className={`video-player${showingControls ? " video-player--controls-visible" : ""}`}
|
||||
className={`video-player${
|
||||
showingControls ? " video-player--controls-visible" : ""
|
||||
}`}
|
||||
onMouseMove={showControlsTemporarily}
|
||||
onMouseLeave={() => playing && setControlsVisible(false)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { Playlist } from "../model.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
@@ -12,7 +14,7 @@ interface NewPlaylistFormProps {
|
||||
export function NewPlaylistForm(
|
||||
{
|
||||
onCreated,
|
||||
toggleLabel = "+ New playlist",
|
||||
toggleLabel,
|
||||
toggleClassName = "new-playlist-toggle",
|
||||
}: NewPlaylistFormProps,
|
||||
) {
|
||||
@@ -25,11 +27,11 @@ export function NewPlaylistForm(
|
||||
className={toggleClassName}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{toggleLabel}
|
||||
{toggleLabel ?? <Trans>+ New playlist</Trans>}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<Modal title="New playlist" onClose={() => setOpen(false)}>
|
||||
<Modal title={t`New playlist`} onClose={() => setOpen(false)}>
|
||||
<PlaylistCreateForm
|
||||
onCreated={(playlist) => {
|
||||
onCreated(playlist);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Playlist } from "../model.ts";
|
||||
@@ -68,7 +68,9 @@ export function PlaylistCard(
|
||||
playlist.isPublic ? "" : " playlist-badge--private"
|
||||
}`}
|
||||
>
|
||||
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
|
||||
{playlist.isPublic
|
||||
? <Trans>public</Trans>
|
||||
: <Trans>private</Trans>}
|
||||
</span>
|
||||
{playlist.ownerUsername && !isOwner && (
|
||||
<Link
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { CountedInput } from "./CountedInput.tsx";
|
||||
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
@@ -27,7 +28,9 @@ export function PlaylistCreateForm(
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (
|
||||
!title.trim() || description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||
) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -64,19 +67,21 @@ export function PlaylistCreateForm(
|
||||
|
||||
return (
|
||||
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
||||
<input
|
||||
<CountedInput
|
||||
type="text"
|
||||
placeholder={t`Title`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
<TextEditor
|
||||
placeholder={t`Description (optional)`}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
<div className="visibility-toggle">
|
||||
<button
|
||||
@@ -94,7 +99,9 @@ export function PlaylistCreateForm(
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
</div>
|
||||
{error && <ErrorCard title={t`Failed to create playlist`} message={error} />}
|
||||
{error && (
|
||||
<ErrorCard title={t`Failed to create playlist`} message={error} />
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<button
|
||||
@@ -107,7 +114,8 @@ export function PlaylistCreateForm(
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting}
|
||||
disabled={submitting ||
|
||||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
{submitting
|
||||
? <Trans>Creating…</Trans>
|
||||
|
||||
@@ -23,9 +23,17 @@ export function PlaylistMembershipPanel({
|
||||
return (
|
||||
<>
|
||||
{loading
|
||||
? <p className="page-loading"><Trans>Loading…</Trans></p>
|
||||
? (
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
)
|
||||
: memberships.length === 0 && !showNewForm
|
||||
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No playlists yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="playlist-membership-list">
|
||||
{memberships.map((m) => (
|
||||
|
||||
@@ -10,9 +10,50 @@ interface RichContentCardProps {
|
||||
export default function RichContentCard(
|
||||
{ richContent, compact = false }: RichContentCardProps,
|
||||
) {
|
||||
const { play } = useContext(PlayerContext);
|
||||
const { play, current, playing } = useContext(PlayerContext);
|
||||
|
||||
if (compact) {
|
||||
if (richContent.embedUrl) {
|
||||
const isActive = current?.kind === "embed" &&
|
||||
current.embedUrl === richContent.embedUrl;
|
||||
const isPlaying = isActive && playing;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isActive ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl: richContent.embedUrl!,
|
||||
title: richContent.title,
|
||||
type: richContent.type,
|
||||
});
|
||||
}}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{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>}
|
||||
<span className="rich-content-play-overlay">
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={richContent.url}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { EmojiPicker } from "frimousse";
|
||||
import { EmojiPicker, type Locale as EmojiLocale } from "frimousse";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { charCountClass } from "../utils/charCount.ts";
|
||||
import { MentionDropdown } from "./MentionDropdown.tsx";
|
||||
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
||||
@@ -29,6 +31,7 @@ interface TextEditorProps {
|
||||
autoResize?: boolean;
|
||||
onSubmit?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
@@ -44,9 +47,11 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
autoResize = false,
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
maxLength,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { i18n } = useLingui();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const emojiViewportRef = useRef<HTMLDivElement>(null);
|
||||
const emojiSearchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -205,6 +210,15 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
id={id}
|
||||
className={className}
|
||||
/>
|
||||
{maxLength != null && (
|
||||
<span
|
||||
className={`text-editor-count${
|
||||
charCountClass(value.length, maxLength)
|
||||
}`}
|
||||
>
|
||||
{value.length} / {maxLength}
|
||||
</span>
|
||||
)}
|
||||
{mentionOpen && (
|
||||
<MentionDropdown
|
||||
results={mentionResults}
|
||||
@@ -248,6 +262,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
>
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
|
||||
locale={i18n.locale as EmojiLocale}
|
||||
>
|
||||
<div className="emoji-picker-search-row">
|
||||
<EmojiPicker.Search
|
||||
@@ -270,8 +285,12 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||
tabIndex={-1}
|
||||
>
|
||||
<EmojiPicker.Loading><Trans>Loading…</Trans></EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty><Trans>No emoji found.</Trans></EmojiPicker.Empty>
|
||||
<EmojiPicker.Loading>
|
||||
<Trans>Loading…</Trans>
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty>
|
||||
<Trans>No emoji found.</Trans>
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List />
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import type { User } from "../model.ts";
|
||||
|
||||
Reference in New Issue
Block a user