v3: added localization, use global player for uploaded audio/video files
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
🔍
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user