v3: localization fixes, char counters & limits on all text fields, ux fixes

This commit is contained in:
khannurien
2026-04-03 19:47:37 +00:00
parent 0ce80398a4
commit a69788c15b
48 changed files with 1133 additions and 305 deletions

View File

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

View File

@@ -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

View File

@@ -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 {

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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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}

View File

@@ -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";

View File

@@ -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">

View File

@@ -108,6 +108,7 @@ export function JournalCard(
? (e) => {
e.stopPropagation();
play({
kind: "embed",
embedUrl,
title: dump.richContent?.title,
type: dump.richContent?.type ?? "unknown",

View File

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

View File

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

View File

@@ -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

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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}

View File

@@ -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>

View File

@@ -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";