v3: added localization, use global player for uploaded audio/video files

This commit is contained in:
khannurien
2026-04-03 15:29:33 +00:00
parent 378b3ffa46
commit 0ce80398a4
64 changed files with 4248 additions and 941 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { t } from "@lingui/core/macro";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
@@ -32,7 +33,7 @@ export function AddToPlaylistModal(
})
.catch(() => {})
.finally(() => setLoading(false));
}, [dumpId]);
}, [dumpId, authFetch]);
const toggleMembership = async (membership: PlaylistMembership) => {
const { playlist, hasDump } = membership;
@@ -60,7 +61,7 @@ export function AddToPlaylistModal(
};
return (
<Modal title="Add to playlist" onClose={onClose}>
<Modal title={t`Add to playlist`} onClose={onClose}>
<PlaylistMembershipPanel
dumpId={dumpId}
memberships={memberships}

View File

@@ -1,5 +1,7 @@
import { type ReactNode, useState } from "react";
import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
@@ -41,7 +43,7 @@ export function AppHeader(
to={`/users/${user.username}/playlists`}
className="app-header-user"
>
Playlists
<Trans>Playlists</Trans>
</Link>
</div>
@@ -56,16 +58,16 @@ export function AppHeader(
className="btn-primary"
onClick={() => setCreateModalOpen(true)}
disabled={disableNew}
title={disableNew ? "Server unreachable" : undefined}
title={disableNew ? t`Server unreachable` : undefined}
>
+ New
<Trans>+ New</Trans>
</button>
</>
)
: (
<>
<button type="button" onClick={() => navigate("/login")}>
Log in
<Trans>Log in</Trans>
</button>
</>
)}
@@ -74,7 +76,8 @@ export function AppHeader(
{wsStatus === "disconnected" && wsErrorMessage && (
<div className="app-header-status" role="alert">
<strong>Live updates unavailable.</strong> {wsErrorMessage}
<strong><Trans>Live updates unavailable.</Trans></strong>{" "}
{wsErrorMessage}
</div>
)}

View File

@@ -1,5 +1,7 @@
import React, { useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type {
Comment,
@@ -103,7 +105,7 @@ function CommentNode({
setReplyError(data.error.message);
}
} catch {
setReplyError("Could not reach the server. Please try again.");
setReplyError(t`Could not reach the server. Please try again.`);
} finally {
setSubmitting(false);
}
@@ -142,7 +144,7 @@ function CommentNode({
setEditError(data.error.message);
}
} catch {
setEditError("Could not reach the server. Please try again.");
setEditError(t`Could not reach the server. Please try again.`);
} finally {
setEditSubmitting(false);
}
@@ -164,7 +166,9 @@ function CommentNode({
/>
</div>
<div className="comment-content">
<p className="comment-deleted-placeholder">[deleted]</p>
<p className="comment-deleted-placeholder">
<Trans>[deleted]</Trans>
</p>
</div>
</div>
{children.length > 0 && (
@@ -222,9 +226,9 @@ function CommentNode({
</Tooltip>
</Link>
{comment.updatedAt && (
<Tooltip text={`Edited ${comment.updatedAt.toLocaleString()}`}>
<Tooltip text={t`Edited ${comment.updatedAt.toLocaleString()}`}>
<span className="comment-edited">
edited {relativeTime(comment.updatedAt)}
<Trans>edited {relativeTime(comment.updatedAt)}</Trans>
</span>
</Tooltip>
)}
@@ -242,7 +246,7 @@ function CommentNode({
rows={1}
/>
{editError && (
<ErrorCard title="Failed to save edit" message={editError} />
<ErrorCard title={t`Failed to save edit`} message={editError} />
)}
<div className="comment-form-actions">
<button
@@ -250,7 +254,7 @@ function CommentNode({
className="comment-submit-btn"
disabled={editSubmitting || !editBody.trim()}
>
{editSubmitting ? "Saving…" : "Save"}
{editSubmitting ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
@@ -261,7 +265,7 @@ function CommentNode({
setEditError(null);
}}
>
Cancel
<Trans>Cancel</Trans>
</button>
</div>
</form>
@@ -277,7 +281,7 @@ function CommentNode({
setTimeout(() => replyEditorRef.current?.focus(), 0);
}}
>
Reply
<Trans>Reply</Trans>
</button>
)}
{canEdit && !editOpen && (
@@ -290,7 +294,7 @@ function CommentNode({
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
>
Edit
<Trans>Edit</Trans>
</button>
)}
{canDelete && !editOpen && (
@@ -299,13 +303,13 @@ function CommentNode({
className="comment-action-btn comment-delete-btn"
onClick={() => setConfirmDelete(true)}
>
Delete
<Trans>Delete</Trans>
</button>
)}
{confirmDelete && (
<ConfirmModal
message="Delete this comment?"
confirmLabel="Delete"
message={t`Delete this comment?`}
confirmLabel={t`Delete`}
onConfirm={() => {
setConfirmDelete(false);
handleDelete();
@@ -322,12 +326,12 @@ function CommentNode({
value={replyBody}
onChange={setReplyBody}
onSubmit={handleReply}
placeholder="Write a reply…"
placeholder={t`Write a reply…`}
autoResize
rows={1}
/>
{replyError && (
<ErrorCard title="Failed to post reply" message={replyError} />
<ErrorCard title={t`Failed to post reply`} message={replyError} />
)}
<div className="comment-form-actions">
<button
@@ -335,7 +339,7 @@ function CommentNode({
className="comment-submit-btn"
disabled={submitting || !replyBody.trim()}
>
{submitting ? "Posting…" : "Post reply"}
{submitting ? <Trans>Posting</Trans> : <Trans>Post reply</Trans>}
</button>
<button
type="button"
@@ -346,7 +350,7 @@ function CommentNode({
setReplyError(null);
}}
>
Cancel
<Trans>Cancel</Trans>
</button>
</div>
</form>
@@ -418,19 +422,18 @@ export function CommentThread({
setTopLevelError(data.error.message);
}
} catch {
setTopLevelError("Could not reach the server. Please try again.");
setTopLevelError(t`Could not reach the server. Please try again.`);
} finally {
setSubmitting(false);
}
}
const visibleCount = comments.filter((c) => !c.deleted).length;
return (
<section className="comment-section">
<h2 className="comment-section-title">
{(() => {
const n = comments.filter((c) => !c.deleted).length;
return n === 1 ? "1 comment" : `${n} comments`;
})()}
<Plural value={visibleCount} one="# comment" other="# comments" />
</h2>
{currentUser && (
@@ -450,13 +453,13 @@ export function CommentThread({
value={topLevelBody}
onChange={setTopLevelBody}
onSubmit={handleTopLevelSubmit}
placeholder="Add a comment…"
placeholder={t`Add a comment…`}
autoResize
rows={1}
/>
{topLevelError && (
<ErrorCard
title="Failed to post comment"
title={t`Failed to post comment`}
message={topLevelError}
/>
)}
@@ -466,7 +469,7 @@ export function CommentThread({
className="comment-submit-btn"
disabled={submitting || !topLevelBody.trim()}
>
{submitting ? "Posting…" : "Post comment"}
{submitting ? <Trans>Posting</Trans> : <Trans>Post comment</Trans>}
</button>
{topLevelBody.trim() && (
<button
@@ -477,7 +480,7 @@ export function CommentThread({
setTopLevelError(null);
}}
>
Cancel
<Trans>Cancel</Trans>
</button>
)}
</div>

View File

@@ -1,5 +1,7 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
interface ConfirmModalProps {
message: string;
@@ -9,8 +11,10 @@ interface ConfirmModalProps {
}
export function ConfirmModal(
{ message, confirmLabel = "Delete", onConfirm, onCancel }: ConfirmModalProps,
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
) {
const label = confirmLabel ?? t`Delete`;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
@@ -24,9 +28,11 @@ export function ConfirmModal(
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<p className="confirm-modal-message">{message}</p>
<div className="confirm-modal-actions">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" onClick={onCancel}>
<Trans>Cancel</Trans>
</button>
<button type="button" className="btn-danger" onClick={onConfirm}>
{confirmLabel}
{label}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { Link, useNavigate } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { dumpUrl } from "../utils/urls.ts";
@@ -78,12 +79,17 @@ export function DumpCard(
</Tooltip>
{dump.commentCount > 0 && (
<span className="dump-card-comment-count">
{dump.commentCount}{" "}
{dump.commentCount === 1 ? "comment" : "comments"}
<Plural
value={dump.commentCount}
one="# comment"
other="# comments"
/>
</span>
)}
{dump.isPrivate && isOwner && (
<span className="dump-card-private-badge">private</span>
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
</div>

View File

@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type {
@@ -26,6 +28,13 @@ import { TextEditor } from "./TextEditor.tsx";
import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
function normalizeUrl(input: string): string {
const s = input.trim();
if (!s || /^https?:\/\//i.test(s)) return s;
if (s.startsWith("//")) return `https:${s}`;
return `https://${s}`;
}
import { MAX_FILE_SIZE } from "../config/upload.ts";
type Mode = "url" | "file";
@@ -42,11 +51,16 @@ type UrlPreview =
| { status: "done"; richContent: RichContent | null };
function LocalFilePreview({ file }: { file: File }) {
const src = useMemo(() => URL.createObjectURL(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;
useEffect(() => () => URL.revokeObjectURL(src), [src]);
if (mime.startsWith("image/")) {
return <img src={src} alt={file.name} className="local-preview-image" />;
}
@@ -92,7 +106,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
let trimmed: string;
try {
const u = new URL(url.trim());
const u = new URL(normalizeUrl(url));
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
trimmed = u.toString();
} catch {
@@ -137,11 +151,11 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
if (tag === "INPUT" || tag === "TEXTAREA") return;
const text = e.clipboardData?.getData("text") ?? "";
try {
const u = new URL(text.trim());
const u = new URL(normalizeUrl(text));
if (u.protocol === "http:" || u.protocol === "https:") {
setMode("url");
setFile(null);
setUrl(text.trim());
setUrl(u.toString());
setSubmitState({ status: "idle" });
}
} catch { /* not a URL */ }
@@ -158,12 +172,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
let res: Response;
if (mode === "url") {
if (!url.trim()) {
setSubmitState({ status: "error", error: "URL is required." });
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) {
setSubmitState({ status: "error", error: t`URL is required.` });
return;
}
setUrl(normalizedUrl);
const body: CreateUrlDumpRequest = {
url: url.trim(),
url: normalizedUrl,
comment: comment.trim() || undefined,
isPrivate,
};
@@ -173,13 +189,16 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
});
} else {
if (!file) {
setSubmitState({ status: "error", error: "Please select a file." });
setSubmitState({
status: "error",
error: t`Please select a file.`,
});
return;
}
if (file.size > MAX_FILE_SIZE) {
setSubmitState({
status: "error",
error: "File too large (max 50 MB).",
error: t`File too large (max 50 MB).`,
});
return;
}
@@ -254,7 +273,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
return (
<Modal
title={phase === "create" ? "New dump" : "Add to playlist"}
title={phase === "create" ? t`New dump` : t`Add to playlist`}
onClose={onClose}
wide
>
@@ -285,14 +304,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
}}
disabled={submitting}
>
📎 File
📎 <Trans>File</Trans>
</button>
</div>
<form onSubmit={handleSubmit} className="dump-form">
{submitState.status === "error" && (
<ErrorCard
title="Failed to post"
title={t`Failed to post`}
message={submitState.error}
/>
)}
@@ -301,12 +320,13 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
? (
<>
<div className="form-group">
<label htmlFor="dc-url">URL</label>
<label htmlFor="dc-url"><Trans>URL</Trans></label>
<input
id="dc-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={(e) => setUrl(normalizeUrl(e.target.value))}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
@@ -325,7 +345,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">Fetching preview</p>
<p className="preview-loading"><Trans>Fetching preview</Trans></p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
@@ -348,14 +368,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
<div className="form-group">
<label htmlFor="dc-comment">
Why are you dumping this?
<Trans>Why are you dumping this?</Trans>
</label>
<TextEditor
id="dc-comment"
value={comment}
onChange={setComment}
disabled={submitting}
placeholder="Tell the community what makes this worth their time..."
placeholder={t`Tell the community what makes this worth their time...`}
rows={3}
/>
</div>
@@ -367,7 +387,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
disabled={submitting}
onClick={() => setIsPrivate(false)}
>
Public
<Trans>Public</Trans>
</button>
<button
type="button"
@@ -375,7 +395,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
disabled={submitting}
onClick={() => setIsPrivate(true)}
>
Private
<Trans>Private</Trans>
</button>
</div>
@@ -386,7 +406,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
className="form-cancel"
onClick={onClose}
>
Cancel
<Trans>Cancel</Trans>
</button>
<button
type="submit"
@@ -394,8 +414,10 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
disabled={submitting}
>
{submitting
? (mode === "url" ? "Fetching…" : "Uploading…")
: "Dump it"}
? (mode === "url"
? <Trans>Fetching</Trans>
: <Trans>Uploading</Trans>)
: <Trans>Dump it</Trans>}
</button>
</div>
</div>
@@ -406,9 +428,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
<>
{createdDump && (
<p className="dump-create-success">
Dumped!{" "}
<Trans>Dumped!</Trans>{" "}
<Link to={dumpUrl(createdDump)} onClick={onClose}>
View dump
<Trans>View dump </Trans>
</Link>
</p>
)}
@@ -429,7 +451,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
className="btn-primary"
onClick={onClose}
>
Done
<Trans>Done</Trans>
</button>
</div>
</div>

View File

@@ -1,13 +1,7 @@
import { useLocation, useNavigate } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts";
export type FeedTab = "hot" | "new" | "journal" | "followed";
export const VALID_TABS = new Set<string>([
"hot",
"new",
"journal",
"followed",
]);
import { type FeedTab, VALID_TABS } from "../config/feedTabs.ts";
export function FeedTabBar() {
const location = useLocation();
@@ -28,21 +22,21 @@ export function FeedTabBar() {
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
onClick={() => setTab("hot")}
>
Hot
<Trans>Hot</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
onClick={() => setTab("new")}
>
New
<Trans>New</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
onClick={() => setTab("journal")}
>
Journal
<Trans>Journal</Trans>
</button>
{user && (
<button
@@ -50,7 +44,7 @@ export function FeedTabBar() {
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
Followed
<Trans>Followed</Trans>
</button>
)}
</div>

View File

@@ -1,4 +1,6 @@
import { useCallback, useRef, useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { formatBytes } from "../utils/format.ts";
function fileIcon(mime: string): string {
@@ -22,10 +24,12 @@ export function FileDropZone({
file,
onChange,
disabled,
label = "File",
hint = "Drop a file here",
label,
hint,
showLimit = true,
}: FileDropZoneProps) {
const resolvedLabel = label ?? t`File`;
const resolvedHint = hint ?? t`Drop a file here`;
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
@@ -69,7 +73,7 @@ export function FileDropZone({
return (
<div className="fdz-wrapper">
{label && <span className="fdz-label">{label}</span>}
{resolvedLabel && <span className="fdz-label">{resolvedLabel}</span>}
<div
className={`fdz${dragging ? " fdz--drag" : ""}${
disabled ? " fdz--disabled" : ""
@@ -108,7 +112,7 @@ export function FileDropZone({
type="button"
className="fdz__clear"
onClick={handleClear}
aria-label="Remove file"
aria-label={t`Remove file`}
>
</button>
@@ -130,11 +134,11 @@ export function FileDropZone({
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p className="fdz__hint">{hint}</p>
<p className="fdz__hint">{resolvedHint}</p>
<p className="fdz__browse">
or <span className="fdz__browse-link">browse files</span>
<Trans>or <span className="fdz__browse-link">browse files</span></Trans>
</p>
{showLimit && <p className="fdz__limit">Max 50 MB</p>}
{showLimit && <p className="fdz__limit"><Trans>Max 50 MB</Trans></p>}
</div>
)}
</div>

View File

@@ -1,11 +1,119 @@
import { useContext, useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { formatBytes } from "../utils/format.ts";
import { MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts";
import {
BAR_GAP,
BAR_W,
extractPeaks,
NUM_BARS,
VIEWBOX_W,
WAVEFORM_H,
} from "../utils/waveform.ts";
interface FilePreviewProps {
dump: Dump;
compact?: boolean;
global?: boolean;
}
// Waveform preview for the dump detail page — routes to global player,
// reflects live play state and position from PlayerContext.
function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) {
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
useContext(PlayerContext);
const [peaks, setPeaks] = useState<Float32Array | null>(null);
const isActive = current?.kind === "file" && current.fileUrl === fileUrl;
const progress = isActive && duration > 0 ? currentTime / duration : 0;
useEffect(() => {
let cancelled = false;
extractPeaks(fileUrl, NUM_BARS)
.then((p) => { if (!cancelled) setPeaks(p); })
.catch(() => {});
return () => { cancelled = true; };
}, [fileUrl]);
const handlePlayBtn = () => {
if (isActive) togglePlay();
else play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
};
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));
if (isActive) {
seekTo(ratio * duration);
} else {
// Start playing and seek once it loads — seekTo after play() is a no-op
// until MediaPlayer mounts; the fraction is best-effort on first click
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}
};
const isPlaying = isActive && playing;
return (
<div className={`audio-file-preview${isActive ? " audio-file-preview--active" : ""}`}>
<button
type="button"
className="audio-player-btn"
onClick={handlePlayBtn}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying
? (
<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" }}>
<polygon points="6,3 20,12 6,21" />
</svg>
)}
</button>
{peaks
? (
<svg
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
preserveAspectRatio="none"
className="waveform-svg"
onClick={handleWaveformClick}
>
{Array.from(peaks).map((p, i) => {
const barH = Math.max(p * WAVEFORM_H, 2);
const x = i * (BAR_W + BAR_GAP);
const y = (WAVEFORM_H - barH) / 2;
const played = i / NUM_BARS <= progress;
return (
<rect
key={i}
x={x}
y={y}
width={BAR_W}
height={barH}
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
/>
);
})}
</svg>
)
: (
<div className="waveform-skeleton" onClick={handleWaveformClick}>
<div
className="waveform-skeleton-fill"
style={{ width: `${progress * 100}%` }}
/>
</div>
)}
</div>
);
}
function mimeIcon(mime: string): string {
@@ -17,10 +125,13 @@ function mimeIcon(mime: string): string {
}
export default function FilePreview(
{ dump, compact = false }: FilePreviewProps,
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
) {
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) {
if (mime.startsWith("image/")) {
@@ -35,6 +146,45 @@ export default function FilePreview(
/>
);
}
if (mime.startsWith("video/")) {
return (
<button
type="button"
className={`rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}}
>
<video
src={fileUrl}
preload="metadata"
className="rich-content-compact-thumbnail"
muted
onLoadedMetadata={(e) => {
(e.target as HTMLVideoElement).currentTime = 0.1;
}}
/>
<span className="rich-content-play-overlay"></span>
</button>
);
}
if (mime.startsWith("audio/")) {
return (
<button
type="button"
className={`rich-content-compact-icon rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}}
>
{mimeIcon(mime)}
</button>
);
}
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
}
@@ -45,10 +195,37 @@ export default function FilePreview(
}
if (mime.startsWith("video/")) {
if (useGlobal) {
const videoActive = isPlaying;
const videoPlaying = videoActive && playing;
return (
<button
type="button"
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
onClick={() => videoActive ? togglePlay() : play({ kind: "file", fileUrl, mimeType: mime, title: dump.title })}
>
<video
src={fileUrl}
preload="metadata"
className="file-preview-video-thumb"
muted
onLoadedMetadata={(e) => {
(e.target as HTMLVideoElement).currentTime = 0.1;
}}
/>
<span className="rich-content-play-overlay">
{videoPlaying ? "⏸" : "▶"}
</span>
</button>
);
}
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />;
}
if (mime.startsWith("audio/")) {
if (useGlobal) {
return <AudioFilePreview fileUrl={fileUrl} mime={mime} dump={dump} />;
}
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
}

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { useFollows } from "../hooks/useFollows.ts";
@@ -29,10 +31,10 @@ export function FollowUserButton(
onClick={() =>
isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
aria-label={isFollowing
? `Unfollow ${targetUsername}`
: `Follow ${targetUsername}`}
? t`Unfollow ${targetUsername}`
: t`Follow ${targetUsername}`}
>
{isFollowing ? "Following" : "Follow"}
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
</button>
);
}
@@ -57,9 +59,9 @@ export function FollowPlaylistButton(
isFollowing
? unfollowPlaylist(targetPlaylistId)
: followPlaylist(targetPlaylistId)}
aria-label={isFollowing ? "Unfollow playlist" : "Follow playlist"}
aria-label={isFollowing ? t`Unfollow playlist` : t`Follow playlist`}
>
{isFollowing ? "Following" : "Follow"}
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
</button>
);
}

View File

@@ -1,10 +1,23 @@
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) {
if (!item) return null;
return item.kind === "embed" ? item.embedUrl : item.fileUrl;
}
export function GlobalPlayer() {
const { current, stop } = useContext(PlayerContext);
const { current, stop, seekRef, toggleRef, onPlayStateChange, onTimeUpdate } =
useContext(PlayerContext);
const ref = useRef<HTMLDivElement>(null);
const [reduced, setReduced] = useState(false);
const [prevKey, setPrevKey] = useState(itemKey(current));
if (prevKey !== itemKey(current)) {
setPrevKey(itemKey(current));
if (current) setReduced(false);
}
useEffect(() => {
if (!current) {
@@ -33,23 +46,21 @@ export function GlobalPlayer() {
};
}, [current]);
useEffect(() => {
if (current) setReduced(false);
}, [current?.embedUrl]);
if (!current) return null;
const typeClass = current.kind === "embed"
? current.type
: current.mimeType.startsWith("video/") ? "file-video" : "file-audio";
const title = current.title ?? (current.kind === "embed" ? current.embedUrl : current.fileUrl);
return (
<div
className={`global-player global-player--${current.type}${
reduced ? " global-player--reduced" : ""
}`}
className={`global-player global-player--${typeClass}${reduced ? " global-player--reduced" : ""}`}
ref={ref}
>
<div className="global-player-header">
<span className="global-player-title">
{current.title ?? current.embedUrl}
</span>
<span className="global-player-title">{title}</span>
<button
type="button"
className="btn btn--ghost"
@@ -62,14 +73,32 @@ export function GlobalPlayer() {
</button>
</div>
<div className="global-player-body">
<div className="global-player-iframe-wrap">
<iframe
src={current.embedUrl}
className={`global-player-iframe--${current.type}`}
allow="autoplay; encrypted-media"
allowFullScreen
/>
</div>
{current.kind === "embed"
? (
<div className="global-player-iframe-wrap">
<iframe
src={current.embedUrl}
className={`global-player-iframe--${current.type}`}
allow="autoplay; encrypted-media"
allowFullScreen
/>
</div>
)
: (
<div className="global-player-media-wrap">
<MediaPlayer
key={current.fileUrl}
src={current.fileUrl}
kind={current.mimeType.startsWith("video/") ? "video" : "audio"}
mime={current.mimeType}
autoplay
onPlayStateChange={onPlayStateChange}
onTimeUpdate={onTimeUpdate}
seekRef={seekRef}
toggleRef={toggleRef}
/>
</div>
)}
</div>
</div>
);

View File

@@ -1,4 +1,12 @@
import { useEffect, useRef, useState } from "react";
import {
BAR_GAP,
BAR_W,
extractPeaks,
NUM_BARS,
VIEWBOX_W,
WAVEFORM_H,
} from "../utils/waveform.ts";
function fmt(s: number): string {
if (!isFinite(s)) return "0:00";
@@ -38,15 +46,91 @@ const IconFullscreen = () => (
</svg>
);
// ── Waveform ──────────────────────────────────────────────────────────────────
function Waveform(
{ src, current, duration, onSeek }: {
src: string;
current: number;
duration: number;
onSeek: (t: number) => void;
},
) {
const [peaks, setPeaks] = useState<Float32Array | null>(null);
useEffect(() => {
let cancelled = false;
extractPeaks(src, NUM_BARS)
.then((p) => { if (!cancelled) setPeaks(p); })
.catch(() => {});
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));
onSeek(ratio * duration);
};
if (!peaks) {
return (
<div className="waveform-skeleton" onClick={handleClick}>
<div
className="waveform-skeleton-fill"
style={{ width: `${progress * 100}%` }}
/>
</div>
);
}
return (
<svg
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
preserveAspectRatio="none"
className="waveform-svg"
onClick={handleClick}
>
{Array.from(peaks).map((p, i) => {
const barH = Math.max(p * WAVEFORM_H, 2);
const x = i * (BAR_W + BAR_GAP);
const y = (WAVEFORM_H - barH) / 2;
const played = i / NUM_BARS <= progress;
return (
<rect
key={i}
x={x}
y={y}
width={BAR_W}
height={barH}
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
/>
);
})}
</svg>
);
}
// ── MediaPlayer ───────────────────────────────────────────────────────────────
const HIDE_DELAY = 2500;
interface MediaPlayerProps {
src: string;
kind: "audio" | "video";
mime?: string;
autoplay?: boolean;
onPlayStateChange?: (playing: boolean) => void;
onTimeUpdate?: (time: number, duration: number) => void;
seekRef?: { current: ((t: number) => void) | null };
toggleRef?: { current: (() => void) | null };
}
export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
export function MediaPlayer(
{ src, kind, mime, autoplay, onPlayStateChange, onTimeUpdate, seekRef, toggleRef }:
MediaPlayerProps,
) {
const mediaRef = useRef<HTMLMediaElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
@@ -57,13 +141,93 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
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.
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).
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(() => {});
}
};
// Stable wrappers used everywhere inside the component
const seekTo = (time: number) => seekToFnRef.current(time);
const toggle = () => toggleFnRef.current();
// ── Effects ──────────────────────────────────────────────────────────────────
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
useEffect(() => {
if (!autoplay) return;
mediaRef.current?.play()
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 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.
useEffect(() => {
const a = mediaRef.current!;
return () => {
a.pause();
onPlayStateChangeRef.current = undefined;
onTimeUpdateRef.current = undefined;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Register imperative handles into provider refs. seekRef/toggleRef are stable
// (created with useRef in PlayerProvider), so this effect runs exactly once.
useEffect(() => {
if (seekRef) seekRef.current = (t) => seekToFnRef.current(t);
if (toggleRef) toggleRef.current = () => toggleFnRef.current();
return () => {
if (seekRef) seekRef.current = null;
if (toggleRef) toggleRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seekRef, toggleRef]);
// Media element event listeners
useEffect(() => {
const a = mediaRef.current!;
const onTime = () => {
if (!dragging) setCurrent(a.currentTime);
if (!dragging) {
setCurrent(a.currentTime);
onTimeUpdateRef.current?.(a.currentTime, a.duration);
}
};
const onDuration = () => {
setDuration(a.duration);
onTimeUpdateRef.current?.(a.currentTime, a.duration);
};
const onEnded = () => {
setPlaying(false);
onPlayStateChangeRef.current?.(false);
};
const onDuration = () => setDuration(a.duration);
const onEnded = () => setPlaying(false);
a.addEventListener("timeupdate", onTime);
a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEnded);
@@ -74,30 +238,18 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
};
}, [dragging]);
// Stop playback on unmount; the browser aborts network requests when the element leaves the DOM.
useEffect(() => {
const a = mediaRef.current!;
return () => {
a.pause();
};
}, []);
// Schedule controls hide when playing; controls are always visible when paused (derived below)
// Hide video controls after inactivity
useEffect(() => {
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]);
// Controls are always visible when paused or for audio; otherwise follow controlsVisible state
// ── Render helpers ────────────────────────────────────────────────────────────
const showingControls = kind !== "video" || !playing || controlsVisible;
const showControlsTemporarily = () => {
@@ -105,26 +257,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
setControlsVisible(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(
() => setControlsVisible(false),
HIDE_DELAY,
);
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
}
};
const toggle = () => {
const a = mediaRef.current!;
if (playing) {
a.pause();
setPlaying(false);
} else a.play().then(() => setPlaying(true)).catch(() => {});
};
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
setCurrent(v);
mediaRef.current!.currentTime = v;
};
const seek = (e: React.ChangeEvent<HTMLInputElement>) => seekTo(Number(e.target.value));
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
@@ -148,24 +285,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
const progress = duration > 0 ? current / duration : 0;
const controls = (
<>
<button
type="button"
className="audio-player-btn"
onClick={toggle}
aria-label={playing ? "Pause" : "Play"}
>
{playing ? <IconPause /> : <IconPlay />}
</button>
<span className="audio-player-time">{fmt(current)}</span>
const track = kind === "audio"
? <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"
@@ -179,6 +303,22 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
aria-label="Seek"
/>
</div>
);
const controls = (
<>
<button
type="button"
className="audio-player-btn"
onClick={toggle}
aria-label={playing ? "Pause" : "Play"}
>
{playing ? <IconPause /> : <IconPlay />}
</button>
<span className="audio-player-time">{fmt(current)}</span>
{track}
<span className="audio-player-time">{fmt(duration)}</span>
@@ -225,9 +365,7 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
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,5 +1,6 @@
import { type ReactNode, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
interface ModalProps {
title: string;
@@ -41,7 +42,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
aria-label={t`Close`}
>
</button>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { useWS } from "../hooks/useWS.ts";
export function NotificationBell() {
@@ -18,12 +19,15 @@ export function NotificationBell() {
if (animatingRef.current) return;
animatingRef.current = true;
setRinging(true);
const t = setTimeout(() => {
const tStart = setTimeout(() => setRinging(true), 0);
const tEnd = setTimeout(() => {
setRinging(false);
animatingRef.current = false;
}, 700);
return () => clearTimeout(t);
return () => {
clearTimeout(tStart);
clearTimeout(tEnd);
};
}, [lastNotification]);
return (
@@ -33,11 +37,9 @@ export function NotificationBell() {
ringing ? " notification-bell--ringing" : ""
}`}
onClick={() => navigate("/notifications")}
aria-label={`Notifications${
unreadNotificationCount > 0
? ` (${unreadNotificationCount} unread)`
: ""
}`}
aria-label={unreadNotificationCount > 0
? t`Notifications (${unreadNotificationCount} unread)`
: t`Notifications`}
>
<span className="notification-bell-icon">🔔</span>
{unreadNotificationCount > 0 && (

View File

@@ -1,18 +1,20 @@
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { PageShell } from "./PageShell.tsx";
import { ErrorCard } from "./ErrorCard.tsx";
export function PageError(
{ title = "Something went wrong", message, actions }: {
{ title, message, actions }: {
title?: string;
message: string;
actions?: ReactNode;
},
) {
const resolvedTitle = title ?? t`Something went wrong`;
return (
<PageShell>
<div className="page-error-wrap">
<ErrorCard title={title} message={message} actions={actions} />
<ErrorCard title={resolvedTitle} message={message} actions={actions} />
</div>
</PageShell>
);

View File

@@ -1,4 +1,6 @@
import { Link, useNavigate } from "react-router";
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";
import { relativeTime } from "../utils/relativeTime.ts";
@@ -66,7 +68,7 @@ export function PlaylistCard(
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
</span>
{playlist.ownerUsername && !isOwner && (
<Link
@@ -79,8 +81,11 @@ export function PlaylistCard(
)}
{playlist.dumpCount !== undefined && (
<span className="playlist-card-count">
{playlist.dumpCount}{" "}
{playlist.dumpCount === 1 ? "dump" : "dumps"}
<Plural
value={playlist.dumpCount}
one="# dump"
other="# dumps"
/>
</span>
)}
<Tooltip text={playlist.createdAt.toLocaleString()}>
@@ -99,7 +104,7 @@ export function PlaylistCard(
e.stopPropagation();
onDelete();
}}
aria-label="Delete playlist"
aria-label={t`Delete playlist`}
>
</button>

View File

@@ -1,4 +1,6 @@
import { useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
@@ -54,7 +56,7 @@ export function PlaylistCreateForm(
}
onCreated(playlist);
} catch {
setError("Failed to create playlist");
setError(t`Failed to create playlist`);
} finally {
setSubmitting(false);
}
@@ -64,14 +66,14 @@ export function PlaylistCreateForm(
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Title"
placeholder={t`Title`}
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
required
/>
<TextEditor
placeholder="Description (optional)"
placeholder={t`Description (optional)`}
value={description}
onChange={setDescription}
rows={3}
@@ -82,17 +84,17 @@ export function PlaylistCreateForm(
className={isPublic ? "active" : ""}
onClick={() => setIsPublic(true)}
>
Public
<Trans>Public</Trans>
</button>
<button
type="button"
className={!isPublic ? "active" : ""}
onClick={() => setIsPublic(false)}
>
Private
<Trans>Private</Trans>
</button>
</div>
{error && <ErrorCard title="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
@@ -100,14 +102,18 @@ export function PlaylistCreateForm(
className="form-cancel"
onClick={onCancel}
>
Cancel
<Trans>Cancel</Trans>
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting}
>
{submitting ? "Creating…" : dumpId ? "Create & Add" : "Create"}
{submitting
? <Trans>Creating</Trans>
: dumpId
? <Trans>Create & Add</Trans>
: <Trans>Create</Trans>}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { Trans } from "@lingui/react/macro";
import type { PlaylistMembership } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
@@ -22,9 +23,9 @@ export function PlaylistMembershipPanel({
return (
<>
{loading
? <p className="page-loading">Loading</p>
? <p className="page-loading"><Trans>Loading</Trans></p>
: memberships.length === 0 && !showNewForm
? <p className="empty-state">No playlists yet.</p>
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
: (
<ul className="playlist-membership-list">
{memberships.map((m) => (
@@ -43,7 +44,7 @@ export function PlaylistMembershipPanel({
</span>
{!m.playlist.isPublic && (
<span className="playlist-badge playlist-badge--private">
private
<Trans>private</Trans>
</span>
)}
</li>
@@ -68,7 +69,7 @@ export function PlaylistMembershipPanel({
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
+ New playlist
<Trans>+ New playlist</Trans>
</button>
)}
</>

View File

@@ -47,6 +47,7 @@ export default function RichContentCard(
className="rich-content-thumbnail-btn"
onClick={() =>
play({
kind: "embed",
embedUrl: richContent.embedUrl!,
title: richContent.title,
type: richContent.type,

View File

@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
interface SearchBarProps {
collapsible?: boolean;
@@ -15,7 +16,7 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
useEffect(() => {
if (collapsible && expanded) inputRef.current?.focus();
}, [expanded]);
}, [expanded, collapsible]);
function handleIconClick() {
if (!collapsible) return;
@@ -57,17 +58,17 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
ref={inputRef}
type="search"
className="search-bar-input"
placeholder="Search dumps, users, playlists…"
placeholder={t`Search dumps, users, playlists…`}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
aria-label="Search"
aria-label={t`Search`}
tabIndex={expanded ? 0 : -1}
/>
<button
type={expanded && !collapsible ? "submit" : "button"}
className="search-bar-btn"
aria-label={expanded ? "Submit search" : "Open search"}
aria-label={expanded ? t`Submit search` : t`Open search`}
onClick={collapsible ? handleIconClick : undefined}
>
🔍

View File

@@ -7,6 +7,7 @@ import {
useState,
} from "react";
import { EmojiPicker } from "frimousse";
import { Trans } from "@lingui/react/macro";
import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
@@ -269,8 +270,8 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
// frimousse's onFocusCapture can detect it and arm arrow-key nav
tabIndex={-1}
>
<EmojiPicker.Loading>Loading</EmojiPicker.Loading>
<EmojiPicker.Empty>No emoji found.</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,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { Avatar } from "./Avatar.tsx";
import type { User } from "../model.ts";
@@ -32,7 +34,7 @@ export function UserMenu({ user }: { user: User }) {
className="user-menu-trigger"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label="User menu"
aria-label={t`User menu`}
>
<Avatar
userId={user.id}
@@ -57,7 +59,7 @@ export function UserMenu({ user }: { user: User }) {
role="menuitem"
onClick={() => setOpen(false)}
>
Playlists
<Trans>Playlists</Trans>
</Link>
</div>
)}