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