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

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

View File

@@ -1,6 +1,14 @@
import type { RichContent } from "../../model/interfaces.ts"; import type { RichContent } from "../../model/interfaces.ts";
import type { RichContentProvider } from "../rich-content-service.ts"; import type { RichContentProvider } from "../rich-content-service.ts";
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; import {
extractBestIcon,
extractJsonLd,
extractLargeImage,
extractMetaName,
extractOgTag,
extractPageTitle,
fetchWithTimeout,
} from "../rich-content-service.ts";
export const genericProvider: RichContentProvider = { export const genericProvider: RichContentProvider = {
name: "generic", name: "generic",
@@ -18,14 +26,39 @@ export const genericProvider: RichContentProvider = {
} }
const html = await res.text(); const html = await res.text();
const ld = extractJsonLd(html);
// Title: og:title → twitter:title → JSON-LD → <title>
const title = extractOgTag(html, "title") ??
extractMetaName(html, "twitter:title") ??
ld.title ??
extractPageTitle(html);
// Description: og:description → twitter:description → JSON-LD → <meta name="description">
const description = extractOgTag(html, "description") ??
extractMetaName(html, "twitter:description") ??
ld.description ??
extractMetaName(html, "description");
// Image: og:image → twitter:image → JSON-LD → first large <img> → best icon → /favicon.ico
const thumbnailUrl = extractOgTag(html, "image") ??
extractMetaName(html, "twitter:image") ??
ld.thumbnailUrl ??
extractLargeImage(html, url) ??
extractBestIcon(html, url) ??
`${new URL(url).origin}/favicon.ico`;
// Site name: og:site_name → hostname
const siteName = extractOgTag(html, "site_name") ??
new URL(url).hostname.replace(/^www\./, "");
return { return {
type: "generic", type: "generic",
url, url,
title: extractOgTag(html, "title"), title,
description: extractOgTag(html, "description"), description,
thumbnailUrl: extractOgTag(html, "image"), thumbnailUrl,
siteName: extractOgTag(html, "site_name"), siteName,
}; };
}, },
}; };

View File

@@ -83,6 +83,199 @@ export function extractOgTag(
return undefined; return undefined;
} }
/** Extract content from `<meta name="…" content="…">` (both attribute orderings). */
export function extractMetaName(
html: string,
name: string,
): string | undefined {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
new RegExp(
`<meta[^>]+name=["']${escaped}["'][^>]+content=["']([^"']+)["']`,
"i",
),
new RegExp(
`<meta[^>]+content=["']([^"']+)["'][^>]+name=["']${escaped}["']`,
"i",
),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match) return decodeHtmlEntities(match[1]);
}
return undefined;
}
/** Extract the text content of the `<title>` element. */
export function extractPageTitle(html: string): string | undefined {
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
}
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
type JsonLdResult = {
title?: string;
description?: string;
thumbnailUrl?: string;
};
function ldString(v: unknown): string | undefined {
if (typeof v === "string" && v.trim()) return v.trim();
if (Array.isArray(v) && typeof v[0] === "string" && v[0].trim()) {
return v[0].trim();
}
return undefined;
}
function ldImage(v: unknown): string | undefined {
if (
typeof v === "string" &&
(v.startsWith("http://") || v.startsWith("https://"))
) return v;
if (Array.isArray(v)) return ldImage(v[0]);
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
return ldImage(o.url ?? o.contentUrl);
}
return undefined;
}
function ldExtractNode(data: unknown): JsonLdResult {
if (Array.isArray(data)) {
for (const item of data) {
const r = ldExtractNode(item);
if (r.title || r.thumbnailUrl) return r;
}
return {};
}
if (!data || typeof data !== "object") return {};
const o = data as Record<string, unknown>;
if (o["@graph"]) return ldExtractNode(o["@graph"]);
return {
title: ldString(o.name ?? o.headline),
description: ldString(o.description),
thumbnailUrl: ldImage(o.image ?? o.thumbnailUrl ?? o.thumbnail),
};
}
/**
* Parse every `<script type="application/ld+json">` block and return the first
* node that yields a title or image. Handles `@graph`, arrays, and the common
* `image` shapes (string, string[], ImageObject).
*/
export function extractJsonLd(html: string): JsonLdResult {
const pattern =
/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(html)) !== null) {
try {
const result = ldExtractNode(JSON.parse(match[1]));
if (result.title || result.thumbnailUrl) return result;
} catch { /* invalid JSON — skip */ }
}
return {};
}
/**
* Return the `src` of the first `<img>` whose declared width or height is at
* least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs.
*/
export function extractLargeImage(
html: string,
baseUrl: string,
minSize = 200,
): string | undefined {
const imgPattern = /<img[^>]+>/gi;
let match: RegExpExecArray | null;
while ((match = imgPattern.exec(html)) !== null) {
const tag = match[0];
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1];
if (!src || src.startsWith("data:")) continue;
const w = parseInt(/\bwidth=["']?(\d+)/i.exec(tag)?.[1] ?? "0");
const h = parseInt(/\bheight=["']?(\d+)/i.exec(tag)?.[1] ?? "0");
if (w >= minSize && h >= minSize) {
try {
return new URL(src, baseUrl).toString();
} catch {
continue;
}
}
}
return undefined;
}
/**
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
* them by declared size (largest wins), and return the best resolved URL.
* Falls back to the first match when no `sizes` attribute is present.
*/
export function extractBestIcon(
html: string,
baseUrl: string,
): string | undefined {
const linkRe = /<link[^>]+>/gi;
const relRe = /\brel=["']([^"']+)["']/i;
const hrefRe = /\bhref=["']([^"']+)["']/i;
const sizesRe = /\bsizes=["']([^"']+)["']/i;
const candidates: { href: string; area: number }[] = [];
let m: RegExpExecArray | null;
while ((m = linkRe.exec(html)) !== null) {
const tag = m[0];
const rel = relRe.exec(tag)?.[1] ?? "";
if (!/\bicon\b/i.test(rel) && !/apple-touch-icon/i.test(rel)) continue;
const href = hrefRe.exec(tag)?.[1];
if (!href) continue;
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
const sm = sizesStr.match(/(\d+)x(\d+)/i);
const area = sm ? parseInt(sm[1]) * parseInt(sm[2]) : 0;
try {
candidates.push({ href: new URL(href, baseUrl).toString(), area });
} catch {
continue;
}
}
if (candidates.length === 0) return undefined;
candidates.sort((a, b) => b.area - a.area);
return candidates[0].href;
}
/**
* Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`,
* resolved to an absolute URL using `baseUrl`.
*/
export function extractLinkHref(
html: string,
relFragment: string,
baseUrl: string,
): string | undefined {
const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
new RegExp(
`<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`,
"i",
),
new RegExp(
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`,
"i",
),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match) {
try {
return new URL(match[1], baseUrl).toString();
} catch {
return undefined;
}
}
}
return undefined;
}
function isPrivateHost(hostname: string): boolean { function isPrivateHost(hostname: string): boolean {
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated. // Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
if (hostname === "localhost" || hostname === "::1") return true; if (hostname === "localhost" || hostname === "::1") return true;

View File

@@ -7,10 +7,14 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Saira:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<title>Dumps</title> <title>Dumps</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,24 @@
{
"name": "gerbeur",
"short_name": "gerbeur",
"start_url": "/",
"display": "standalone",
"background_color": "#111827",
"theme_color": "#111827",
"icons": [
{
"src": "/favicon.svg",
"type": "image/svg+xml",
"sizes": "any"
}
],
"share_target": {
"action": "/",
"method": "GET",
"params": {
"url": "share_url",
"title": "share_title",
"text": "share_text"
}
}
}

2
public/sw.js Normal file
View File

@@ -0,0 +1,2 @@
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', () => self.clients.claim());

View File

@@ -539,7 +539,11 @@
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface) 92%); background: color-mix(
in srgb,
var(--color-accent) 8%,
var(--color-surface) 92%
);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
} }
@@ -740,7 +744,11 @@
min-width: 0; min-width: 0;
height: 48px; height: 48px;
border-radius: 3px; border-radius: 3px;
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-border) 88%); background: color-mix(
in srgb,
var(--color-accent) 12%,
var(--color-border) 88%
);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
@@ -2354,7 +2362,6 @@ body.has-player .fab-new {
justify-content: center; justify-content: center;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-bg); background: var(--color-bg);
transition: transform 0.18s ease, box-shadow 0.18s ease; transition: transform 0.18s ease, box-shadow 0.18s ease;
} }
@@ -2370,6 +2377,7 @@ body.has-player .fab-new {
.playlist-card-icon { .playlist-card-icon {
font-size: 1.4rem; font-size: 1.4rem;
opacity: 0.7; opacity: 0.7;
line-height: 1;
} }
/* Fill the 48×48 preview box and center content for media buttons */ /* Fill the 48×48 preview box and center content for media buttons */
@@ -2380,10 +2388,16 @@ body.has-player .fab-new {
justify-content: center; justify-content: center;
} }
.dump-card-preview .rich-content-compact {
width: 48px;
height: 48px;
justify-content: center;
}
.dump-card-preview .rich-content-compact-thumbnail { .dump-card-preview .rich-content-compact-thumbnail {
width: 100%; width: 48px;
height: 100%; height: 48px;
object-fit: cover; object-fit: contain;
border-radius: 0; border-radius: 0;
border: none; border: none;
} }
@@ -3450,10 +3464,12 @@ body.has-player .fab-new {
.journal-card--large .journal-card-comment { .journal-card--large .journal-card-comment {
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
line-clamp: 3;
} }
.journal-card--medium .journal-card-comment { .journal-card--medium .journal-card-comment {
-webkit-line-clamp: 1; -webkit-line-clamp: 1;
line-clamp: 1;
} }
.journal-card-footer { .journal-card-footer {
@@ -3791,6 +3807,28 @@ body.has-player .fab-new {
outline-offset: -2px; outline-offset: -2px;
} }
.input-with-count {
display: flex;
flex-direction: column;
width: 100%;
}
.text-editor-count {
display: block;
text-align: right;
font-size: 0.7rem;
color: var(--color-text-muted);
margin-top: 2px;
user-select: none;
}
.text-editor-count--warn {
color: var(--color-warning, #c97a00);
}
.text-editor-count--danger {
color: var(--color-danger, #c0392b);
font-weight: 600;
}
.mention-dropdown { .mention-dropdown {
position: absolute; position: absolute;
top: 100%; top: 100%;

View File

@@ -1,6 +1,6 @@
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
@@ -9,12 +9,16 @@ import { NotificationBell } from "./NotificationBell.tsx";
import { UserMenu } from "./UserMenu.tsx"; import { UserMenu } from "./UserMenu.tsx";
export function AppHeader( export function AppHeader(
{ centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean }, { centerSlot, disableNew, initialDumpUrl }: {
centerSlot?: ReactNode;
disableNew?: boolean;
initialDumpUrl?: string;
},
) { ) {
const { user } = useAuth(); const { user } = useAuth();
const { wsStatus, wsErrorMessage } = useWS(); const { wsStatus, wsErrorMessage } = useWS();
const navigate = useNavigate(); const navigate = useNavigate();
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl);
return ( return (
<> <>
@@ -76,13 +80,18 @@ export function AppHeader(
{wsStatus === "disconnected" && wsErrorMessage && ( {wsStatus === "disconnected" && wsErrorMessage && (
<div className="app-header-status" role="alert"> <div className="app-header-status" role="alert">
<strong><Trans>Live updates unavailable.</Trans></strong>{" "} <strong>
<Trans>Live updates unavailable.</Trans>
</strong>{" "}
{wsErrorMessage} {wsErrorMessage}
</div> </div>
)} )}
{createModalOpen && ( {createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} /> <DumpCreateModal
onClose={() => setCreateModalOpen(false)}
initialUrl={initialDumpUrl}
/>
)} )}
</> </>
); );

View File

@@ -1,8 +1,8 @@
import React, { useMemo, useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import type { import type {
Comment, Comment,
CreateCommentRequest, CreateCommentRequest,
@@ -79,7 +79,10 @@ function CommentNode({
async function handleReply(e?: React.SubmitEvent) { async function handleReply(e?: React.SubmitEvent) {
e?.preventDefault(); e?.preventDefault();
if (!replyBody.trim() || !token) return; if (
!replyBody.trim() || !token ||
replyBody.length > VALIDATION.COMMENT_BODY_MAX
) return;
setSubmitting(true); setSubmitting(true);
setReplyError(null); setReplyError(null);
try { try {
@@ -124,7 +127,10 @@ function CommentNode({
async function handleEditSave(e?: React.SubmitEvent) { async function handleEditSave(e?: React.SubmitEvent) {
e?.preventDefault(); e?.preventDefault();
if (!editBody.trim() || !token) return; if (
!editBody.trim() || !token ||
editBody.length > VALIDATION.COMMENT_BODY_MAX
) return;
setEditSubmitting(true); setEditSubmitting(true);
setEditError(null); setEditError(null);
try { try {
@@ -244,17 +250,24 @@ function CommentNode({
onSubmit={handleEditSave} onSubmit={handleEditSave}
autoResize autoResize
rows={1} rows={1}
maxLength={VALIDATION.COMMENT_BODY_MAX}
/> />
{editError && ( {editError && (
<ErrorCard title={t`Failed to save edit`} message={editError} /> <ErrorCard
title={t`Failed to save edit`}
message={editError}
/>
)} )}
<div className="comment-form-actions"> <div className="comment-form-actions">
<button <button
type="submit" type="submit"
className="comment-submit-btn" className="comment-submit-btn"
disabled={editSubmitting || !editBody.trim()} disabled={editSubmitting || !editBody.trim() ||
editBody.length > VALIDATION.COMMENT_BODY_MAX}
> >
{editSubmitting ? <Trans>Saving</Trans> : <Trans>Save</Trans>} {editSubmitting
? <Trans>Saving</Trans>
: <Trans>Save</Trans>}
</button> </button>
<button <button
type="button" type="button"
@@ -329,17 +342,24 @@ function CommentNode({
placeholder={t`Write a reply…`} placeholder={t`Write a reply…`}
autoResize autoResize
rows={1} rows={1}
maxLength={VALIDATION.COMMENT_BODY_MAX}
/> />
{replyError && ( {replyError && (
<ErrorCard title={t`Failed to post reply`} message={replyError} /> <ErrorCard
title={t`Failed to post reply`}
message={replyError}
/>
)} )}
<div className="comment-form-actions"> <div className="comment-form-actions">
<button <button
type="submit" type="submit"
className="comment-submit-btn" className="comment-submit-btn"
disabled={submitting || !replyBody.trim()} disabled={submitting || !replyBody.trim() ||
replyBody.length > VALIDATION.COMMENT_BODY_MAX}
> >
{submitting ? <Trans>Posting</Trans> : <Trans>Post reply</Trans>} {submitting
? <Trans>Posting</Trans>
: <Trans>Post reply</Trans>}
</button> </button>
<button <button
type="button" type="button"
@@ -400,7 +420,10 @@ export function CommentThread({
async function handleTopLevelSubmit(e?: React.SubmitEvent) { async function handleTopLevelSubmit(e?: React.SubmitEvent) {
e?.preventDefault(); e?.preventDefault();
if (!topLevelBody.trim() || !token) return; if (
!topLevelBody.trim() || !token ||
topLevelBody.length > VALIDATION.COMMENT_BODY_MAX
) return;
setSubmitting(true); setSubmitting(true);
setTopLevelError(null); setTopLevelError(null);
try { try {
@@ -456,6 +479,7 @@ export function CommentThread({
placeholder={t`Add a comment…`} placeholder={t`Add a comment…`}
autoResize autoResize
rows={1} rows={1}
maxLength={VALIDATION.COMMENT_BODY_MAX}
/> />
{topLevelError && ( {topLevelError && (
<ErrorCard <ErrorCard
@@ -467,9 +491,12 @@ export function CommentThread({
<button <button
type="submit" type="submit"
className="comment-submit-btn" className="comment-submit-btn"
disabled={submitting || !topLevelBody.trim()} disabled={submitting || !topLevelBody.trim() ||
topLevelBody.length > VALIDATION.COMMENT_BODY_MAX}
> >
{submitting ? <Trans>Posting</Trans> : <Trans>Post comment</Trans>} {submitting
? <Trans>Posting</Trans>
: <Trans>Post comment</Trans>}
</button> </button>
{topLevelBody.trim() && ( {topLevelBody.trim() && (
<button <button

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
interface ConfirmModalProps { interface ConfirmModalProps {

View File

@@ -0,0 +1,22 @@
import type React from "react";
import { charCountClass } from "../utils/charCount.ts";
interface CountedInputProps
extends
Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> {
value: string;
maxLength: number;
}
export function CountedInput({ value, maxLength, ...rest }: CountedInputProps) {
const countClass = charCountClass(value.length, maxLength);
return (
<div className="input-with-count">
<input value={value} maxLength={maxLength} {...rest} />
<span className={`text-editor-count${countClass}`}>
{value.length} / {maxLength}
</span>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import type { import type {
CreateUrlDumpRequest, CreateUrlDumpRequest,
Dump, Dump,
@@ -51,16 +51,20 @@ type UrlPreview =
| { status: "done"; richContent: RichContent | null }; | { status: "done"; richContent: RichContent | null };
function LocalFilePreview({ file }: { file: File }) { function LocalFilePreview({ file }: { file: File }) {
// useRef instead of useMemo+useEffect: StrictMode double-invokes effect const [src, setSrc] = useState<string | null>(null);
// 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(() => {
const url = URL.createObjectURL(file);
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSrc(url);
return () => {
URL.revokeObjectURL(url);
};
}, [file]);
if (!src) return null;
const mime = file.type;
if (mime.startsWith("image/")) { if (mime.startsWith("image/")) {
return <img src={src} alt={file.name} className="local-preview-image" />; return <img src={src} alt={file.name} className="local-preview-image" />;
} }
@@ -75,9 +79,12 @@ function LocalFilePreview({ file }: { file: File }) {
interface DumpCreateModalProps { interface DumpCreateModalProps {
onClose: () => void; onClose: () => void;
initialUrl?: string;
} }
export function DumpCreateModal({ onClose }: DumpCreateModalProps) { export function DumpCreateModal(
{ onClose, initialUrl = "" }: DumpCreateModalProps,
) {
const { authFetch } = useAuth(); const { authFetch } = useAuth();
const { injectDump } = useWS(); const { injectDump } = useWS();
@@ -86,7 +93,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
// Create phase state // Create phase state
const [mode, setMode] = useState<Mode>("url"); const [mode, setMode] = useState<Mode>("url");
const [url, setUrl] = useState(""); const [url, setUrl] = useState(initialUrl);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false); const [isPrivate, setIsPrivate] = useState(false);
@@ -166,6 +173,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (comment.length > VALIDATION.DUMP_COMMENT_MAX) return;
setSubmitState({ status: "submitting" }); setSubmitState({ status: "submitting" });
try { try {
@@ -320,7 +328,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
? ( ? (
<> <>
<div className="form-group"> <div className="form-group">
<label htmlFor="dc-url"><Trans>URL</Trans></label> <label htmlFor="dc-url">
<Trans>URL</Trans>
</label>
<input <input
id="dc-url" id="dc-url"
type="url" type="url"
@@ -345,7 +355,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
/> />
</div> </div>
{urlPreview.status === "loading" && ( {urlPreview.status === "loading" && (
<p className="preview-loading"><Trans>Fetching preview</Trans></p> <p className="preview-loading">
<Trans>Fetching preview</Trans>
</p>
)} )}
{urlPreview.status === "done" && {urlPreview.status === "done" &&
urlPreview.richContent && ( urlPreview.richContent && (
@@ -377,6 +389,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
disabled={submitting} disabled={submitting}
placeholder={t`Tell the community what makes this worth their time...`} placeholder={t`Tell the community what makes this worth their time...`}
rows={3} rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
/> />
</div> </div>
@@ -411,7 +424,8 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
<button <button
type="submit" type="submit"
className="btn-primary" className="btn-primary"
disabled={submitting} disabled={submitting ||
comment.length > VALIDATION.DUMP_COMMENT_MAX}
> >
{submitting {submitting
? (mode === "url" ? (mode === "url"

View File

@@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { formatBytes } from "../utils/format.ts"; import { formatBytes } from "../utils/format.ts";
@@ -136,9 +136,15 @@ export function FileDropZone({
</svg> </svg>
<p className="fdz__hint">{resolvedHint}</p> <p className="fdz__hint">{resolvedHint}</p>
<p className="fdz__browse"> <p className="fdz__browse">
<Trans>or <span className="fdz__browse-link">browse files</span></Trans> <Trans>
or <span className="fdz__browse-link">browse files</span>
</Trans>
</p> </p>
{showLimit && <p className="fdz__limit"><Trans>Max 50 MB</Trans></p>} {showLimit && (
<p className="fdz__limit">
<Trans>Max 50 MB</Trans>
</p>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -33,9 +33,13 @@ function AudioFilePreview(
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
extractPeaks(fileUrl, NUM_BARS) extractPeaks(fileUrl, NUM_BARS)
.then((p) => { if (!cancelled) setPeaks(p); }) .then((p) => {
if (!cancelled) setPeaks(p);
})
.catch(() => {}); .catch(() => {});
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [fileUrl]); }, [fileUrl]);
const handlePlayBtn = () => { const handlePlayBtn = () => {
@@ -45,7 +49,10 @@ function AudioFilePreview(
const handleWaveformClick = (e: React.MouseEvent<Element>) => { const handleWaveformClick = (e: React.MouseEvent<Element>) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const ratio = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width),
);
if (isActive) { if (isActive) {
seekTo(ratio * duration); seekTo(ratio * duration);
} else { } else {
@@ -58,7 +65,11 @@ function AudioFilePreview(
const isPlaying = isActive && playing; const isPlaying = isActive && playing;
return ( return (
<div className={`audio-file-preview${isActive ? " audio-file-preview--active" : ""}`}> <div
className={`audio-file-preview${
isActive ? " audio-file-preview--active" : ""
}`}
>
<button <button
type="button" type="button"
className="audio-player-btn" className="audio-player-btn"
@@ -67,13 +78,21 @@ function AudioFilePreview(
> >
{isPlaying {isPlaying
? ( ? (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}> <svg
viewBox="0 0 24 24"
fill="currentColor"
style={{ padding: "1px" }}
>
<rect x="5" y="3" width="4" height="18" rx="1" /> <rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" /> <rect x="15" y="3" width="4" height="18" rx="1" />
</svg> </svg>
) )
: ( : (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}> <svg
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginLeft: "2px" }}
>
<polygon points="6,3 20,12 6,21" /> <polygon points="6,3 20,12 6,21" />
</svg> </svg>
)} )}
@@ -98,7 +117,9 @@ function AudioFilePreview(
y={y} y={y}
width={BAR_W} width={BAR_W}
height={barH} height={barH}
className={`waveform-bar${played ? " waveform-bar--played" : ""}`} className={`waveform-bar${
played ? " waveform-bar--played" : ""
}`}
/> />
); );
})} })}
@@ -130,7 +151,6 @@ export default function FilePreview(
const { current, playing, play, togglePlay } = useContext(PlayerContext); const { current, playing, play, togglePlay } = useContext(PlayerContext);
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`; const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
const mime = dump.fileMime ?? ""; const mime = dump.fileMime ?? "";
const isMedia = mime.startsWith("video/") || mime.startsWith("audio/");
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl; const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
if (compact) { if (compact) {
@@ -150,7 +170,9 @@ export default function FilePreview(
return ( return (
<button <button
type="button" type="button"
className={`rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`} className={`rich-content-thumbnail-btn${
isPlaying ? " is-playing" : ""
}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -174,14 +196,16 @@ export default function FilePreview(
return ( return (
<button <button
type="button" type="button"
className={`rich-content-compact-icon rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`} className={`rich-content-thumbnail-btn${
isPlaying ? " is-playing" : ""
}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title }); play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}} }}
> >
{mimeIcon(mime)} <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>
</button> </button>
); );
} }
@@ -202,7 +226,13 @@ export default function FilePreview(
<button <button
type="button" type="button"
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`} className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
onClick={() => videoActive ? togglePlay() : play({ kind: "file", fileUrl, mimeType: mime, title: dump.title })} onClick={() =>
videoActive ? togglePlay() : play({
kind: "file",
fileUrl,
mimeType: mime,
title: dump.title,
})}
> >
<video <video
src={fileUrl} src={fileUrl}

View File

@@ -1,4 +1,4 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useFollows } from "../hooks/useFollows.ts"; import { useFollows } from "../hooks/useFollows.ts";

View File

@@ -2,7 +2,9 @@ import { useContext, useEffect, useRef, useState } from "react";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import { PlayerContext } from "../contexts/PlayerContext.ts";
import { MediaPlayer } from "./MediaPlayer.tsx"; import { MediaPlayer } from "./MediaPlayer.tsx";
function itemKey(item: { kind: string; embedUrl?: string; fileUrl?: string } | null) { function itemKey(
item: { kind: string; embedUrl?: string; fileUrl?: string } | null,
) {
if (!item) return null; if (!item) return null;
return item.kind === "embed" ? item.embedUrl : item.fileUrl; return item.kind === "embed" ? item.embedUrl : item.fileUrl;
} }
@@ -50,13 +52,18 @@ export function GlobalPlayer() {
const typeClass = current.kind === "embed" const typeClass = current.kind === "embed"
? current.type ? current.type
: current.mimeType.startsWith("video/") ? "file-video" : "file-audio"; : current.mimeType.startsWith("video/")
? "file-video"
: "file-audio";
const title = current.title ?? (current.kind === "embed" ? current.embedUrl : current.fileUrl); const title = current.title ??
(current.kind === "embed" ? current.embedUrl : current.fileUrl);
return ( return (
<div <div
className={`global-player global-player--${typeClass}${reduced ? " global-player--reduced" : ""}`} className={`global-player global-player--${typeClass}${
reduced ? " global-player--reduced" : ""
}`}
ref={ref} ref={ref}
> >
<div className="global-player-header"> <div className="global-player-header">

View File

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

View File

@@ -61,16 +61,23 @@ function Waveform(
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
extractPeaks(src, NUM_BARS) extractPeaks(src, NUM_BARS)
.then((p) => { if (!cancelled) setPeaks(p); }) .then((p) => {
if (!cancelled) setPeaks(p);
})
.catch(() => {}); .catch(() => {});
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [src]); }, [src]);
const progress = duration > 0 ? current / duration : 0; const progress = duration > 0 ? current / duration : 0;
const handleClick = (e: React.MouseEvent<Element>) => { const handleClick = (e: React.MouseEvent<Element>) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const ratio = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width),
);
onSeek(ratio * duration); onSeek(ratio * duration);
}; };
@@ -128,8 +135,16 @@ interface MediaPlayerProps {
} }
export function MediaPlayer( export function MediaPlayer(
{ src, kind, mime, autoplay, onPlayStateChange, onTimeUpdate, seekRef, toggleRef }: {
MediaPlayerProps, src,
kind,
mime,
autoplay,
onPlayStateChange,
onTimeUpdate,
seekRef,
toggleRef,
}: MediaPlayerProps,
) { ) {
const mediaRef = useRef<HTMLMediaElement>(null); const mediaRef = useRef<HTMLMediaElement>(null);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
@@ -141,24 +156,34 @@ export function MediaPlayer(
const [controlsVisible, setControlsVisible] = useState(true); const [controlsVisible, setControlsVisible] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Callback refs (mutated in render, never trigger effects as deps) ───────── // ── Callback refs ─────────────────────────────────────────────────────────────
// Updating refs in render is safe: they're only read in event handlers / effects, // Updated via effects (after render) so the linter doesn't flag render-phase ref
// never during render. This avoids the stale-closure problem without extra effects. // mutation. No cleanup → no null window. Negligible stale-window between commit
// and effect, acceptable since these are only called from async event handlers.
const onPlayStateChangeRef = useRef(onPlayStateChange); const onPlayStateChangeRef = useRef(onPlayStateChange);
const onTimeUpdateRef = useRef(onTimeUpdate); const onTimeUpdateRef = useRef(onTimeUpdate);
// Sync prop callbacks after every render
useEffect(() => {
onPlayStateChangeRef.current = onPlayStateChange; onPlayStateChangeRef.current = onPlayStateChange;
onTimeUpdateRef.current = onTimeUpdate; onTimeUpdateRef.current = onTimeUpdate;
});
// Stable function refs — logic updated in render, registered once via a stable lambda. // Stable function refs — updated via effects, indirected by the registration
// This avoids the "no-deps effect" anti-pattern that created brief null windows on // effect below so external seekRef/toggleRef never see a null window.
// every re-render (timeupdate fires 4×/s → ref nulled & re-registered each time).
const seekToFnRef = useRef((_t: number) => {}); const seekToFnRef = useRef((_t: number) => {});
const toggleFnRef = useRef(() => {});
// seekTo: all captured values are stable (setCurrent from useState, mediaRef DOM ref)
useEffect(() => {
seekToFnRef.current = (time: number) => { seekToFnRef.current = (time: number) => {
setCurrent(time); setCurrent(time);
mediaRef.current!.currentTime = time; mediaRef.current!.currentTime = time;
}; };
}, []);
const toggleFnRef = useRef(() => {}); // toggle: captures `playing` — re-synced whenever play state changes
useEffect(() => {
toggleFnRef.current = () => { toggleFnRef.current = () => {
const a = mediaRef.current!; const a = mediaRef.current!;
if (playing) { if (playing) {
@@ -167,10 +192,14 @@ export function MediaPlayer(
onPlayStateChangeRef.current?.(false); onPlayStateChangeRef.current?.(false);
} else { } else {
a.play() a.play()
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); }) .then(() => {
setPlaying(true);
onPlayStateChangeRef.current?.(true);
})
.catch(() => {}); .catch(() => {});
} }
}; };
}, [playing]);
// Stable wrappers used everywhere inside the component // Stable wrappers used everywhere inside the component
const seekTo = (time: number) => seekToFnRef.current(time); const seekTo = (time: number) => seekToFnRef.current(time);
@@ -182,10 +211,12 @@ export function MediaPlayer(
useEffect(() => { useEffect(() => {
if (!autoplay) return; if (!autoplay) return;
mediaRef.current?.play() mediaRef.current?.play()
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); }) .then(() => {
setPlaying(true);
onPlayStateChangeRef.current?.(true);
})
.catch(() => {}); .catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps }, [autoplay]);
}, []);
// On unmount: pause and cut callbacks so stale timeupdate/ended events that fire // 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. // between React's commit and the listener-removal effect can't reach the provider.
@@ -196,7 +227,6 @@ export function MediaPlayer(
onPlayStateChangeRef.current = undefined; onPlayStateChangeRef.current = undefined;
onTimeUpdateRef.current = undefined; onTimeUpdateRef.current = undefined;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Register imperative handles into provider refs. seekRef/toggleRef are stable // Register imperative handles into provider refs. seekRef/toggleRef are stable
@@ -208,7 +238,6 @@ export function MediaPlayer(
if (seekRef) seekRef.current = null; if (seekRef) seekRef.current = null;
if (toggleRef) toggleRef.current = null; if (toggleRef) toggleRef.current = null;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seekRef, toggleRef]); }, [seekRef, toggleRef]);
// Media element event listeners // Media element event listeners
@@ -243,9 +272,14 @@ export function MediaPlayer(
if (kind !== "video") return; if (kind !== "video") return;
if (hideTimer.current) clearTimeout(hideTimer.current); if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) { 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]); }, [playing, kind]);
// ── Render helpers ──────────────────────────────────────────────────────────── // ── Render helpers ────────────────────────────────────────────────────────────
@@ -257,11 +291,15 @@ export function MediaPlayer(
setControlsVisible(true); setControlsVisible(true);
if (hideTimer.current) clearTimeout(hideTimer.current); if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) { if (playing) {
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY); hideTimer.current = setTimeout(
() => setControlsVisible(false),
HIDE_DELAY,
);
} }
}; };
const seek = (e: React.ChangeEvent<HTMLInputElement>) => seekTo(Number(e.target.value)); const seek = (e: React.ChangeEvent<HTMLInputElement>) =>
seekTo(Number(e.target.value));
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => { const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value); const v = Number(e.target.value);
@@ -286,10 +324,20 @@ export function MediaPlayer(
const progress = duration > 0 ? current / duration : 0; const progress = duration > 0 ? current / duration : 0;
const track = kind === "audio" const track = kind === "audio"
? <Waveform src={src} current={current} duration={duration} onSeek={seekTo} /> ? (
<Waveform
src={src}
current={current}
duration={duration}
onSeek={seekTo}
/>
)
: ( : (
<div className="audio-player-track"> <div className="audio-player-track">
<div className="audio-player-fill" style={{ width: `${progress * 100}%` }} /> <div
className="audio-player-fill"
style={{ width: `${progress * 100}%` }}
/>
<input <input
type="range" type="range"
className="audio-player-range" className="audio-player-range"
@@ -365,7 +413,9 @@ export function MediaPlayer(
if (kind === "video") { if (kind === "video") {
return ( return (
<div <div
className={`video-player${showingControls ? " video-player--controls-visible" : ""}`} className={`video-player${
showingControls ? " video-player--controls-visible" : ""
}`}
onMouseMove={showControlsTemporarily} onMouseMove={showControlsTemporarily}
onMouseLeave={() => playing && setControlsVisible(false)} onMouseLeave={() => playing && setControlsVisible(false)}
> >

View File

@@ -1,4 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import type { Playlist } from "../model.ts"; import type { Playlist } from "../model.ts";
import { Modal } from "./Modal.tsx"; import { Modal } from "./Modal.tsx";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
@@ -12,7 +14,7 @@ interface NewPlaylistFormProps {
export function NewPlaylistForm( export function NewPlaylistForm(
{ {
onCreated, onCreated,
toggleLabel = "+ New playlist", toggleLabel,
toggleClassName = "new-playlist-toggle", toggleClassName = "new-playlist-toggle",
}: NewPlaylistFormProps, }: NewPlaylistFormProps,
) { ) {
@@ -25,11 +27,11 @@ export function NewPlaylistForm(
className={toggleClassName} className={toggleClassName}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
{toggleLabel} {toggleLabel ?? <Trans>+ New playlist</Trans>}
</button> </button>
{open && ( {open && (
<Modal title="New playlist" onClose={() => setOpen(false)}> <Modal title={t`New playlist`} onClose={() => setOpen(false)}>
<PlaylistCreateForm <PlaylistCreateForm
onCreated={(playlist) => { onCreated={(playlist) => {
onCreated(playlist); onCreated(playlist);

View File

@@ -1,5 +1,5 @@
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import type { Playlist } from "../model.ts"; import type { Playlist } from "../model.ts";
@@ -68,7 +68,9 @@ export function PlaylistCard(
playlist.isPublic ? "" : " playlist-badge--private" playlist.isPublic ? "" : " playlist-badge--private"
}`} }`}
> >
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>} {playlist.isPublic
? <Trans>public</Trans>
: <Trans>private</Trans>}
</span> </span>
{playlist.ownerUsername && !isOwner && ( {playlist.ownerUsername && !isOwner && (
<Link <Link

View File

@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "./CountedInput.tsx";
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts"; import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist, parseAPIResponse } from "../model.ts"; import { deserializePlaylist, parseAPIResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
@@ -27,7 +28,9 @@ export function PlaylistCreateForm(
const handleSubmit = async (e: React.SubmitEvent) => { const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault(); e.preventDefault();
if (!title.trim()) return; if (
!title.trim() || description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return;
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
@@ -64,19 +67,21 @@ export function PlaylistCreateForm(
return ( return (
<form className="modal-new-playlist-form" onSubmit={handleSubmit}> <form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<input <CountedInput
type="text" type="text"
placeholder={t`Title`} placeholder={t`Title`}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
autoFocus autoFocus
required required
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
/> />
<TextEditor <TextEditor
placeholder={t`Description (optional)`} placeholder={t`Description (optional)`}
value={description} value={description}
onChange={setDescription} onChange={setDescription}
rows={3} rows={3}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/> />
<div className="visibility-toggle"> <div className="visibility-toggle">
<button <button
@@ -94,7 +99,9 @@ export function PlaylistCreateForm(
<Trans>Private</Trans> <Trans>Private</Trans>
</button> </button>
</div> </div>
{error && <ErrorCard title={t`Failed to create playlist`} message={error} />} {error && (
<ErrorCard title={t`Failed to create playlist`} message={error} />
)}
<div className="form-actions"> <div className="form-actions">
<div className="form-actions-right"> <div className="form-actions-right">
<button <button
@@ -107,7 +114,8 @@ export function PlaylistCreateForm(
<button <button
type="submit" type="submit"
className="btn-primary" className="btn-primary"
disabled={submitting} disabled={submitting ||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
> >
{submitting {submitting
? <Trans>Creating</Trans> ? <Trans>Creating</Trans>

View File

@@ -23,9 +23,17 @@ export function PlaylistMembershipPanel({
return ( return (
<> <>
{loading {loading
? <p className="page-loading"><Trans>Loading</Trans></p> ? (
<p className="page-loading">
<Trans>Loading</Trans>
</p>
)
: memberships.length === 0 && !showNewForm : memberships.length === 0 && !showNewForm
? <p className="empty-state"><Trans>No playlists yet.</Trans></p> ? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: ( : (
<ul className="playlist-membership-list"> <ul className="playlist-membership-list">
{memberships.map((m) => ( {memberships.map((m) => (

View File

@@ -10,9 +10,50 @@ interface RichContentCardProps {
export default function RichContentCard( export default function RichContentCard(
{ richContent, compact = false }: RichContentCardProps, { richContent, compact = false }: RichContentCardProps,
) { ) {
const { play } = useContext(PlayerContext); const { play, current, playing } = useContext(PlayerContext);
if (compact) { if (compact) {
if (richContent.embedUrl) {
const isActive = current?.kind === "embed" &&
current.embedUrl === richContent.embedUrl;
const isPlaying = isActive && playing;
return (
<button
type="button"
className={`rich-content-thumbnail-btn${
isActive ? " is-playing" : ""
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
play({
kind: "embed",
embedUrl: richContent.embedUrl!,
title: richContent.title,
type: richContent.type,
});
}}
aria-label={isPlaying ? "Pause" : "Play"}
>
{richContent.thumbnailUrl
? (
<img
src={richContent.thumbnailUrl}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)
: <span className="rich-content-compact-icon"></span>}
<span className="rich-content-play-overlay">
{isPlaying ? "⏸" : "▶"}
</span>
</button>
);
}
return ( return (
<a <a
href={richContent.url} href={richContent.url}

View File

@@ -6,8 +6,10 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { EmojiPicker } from "frimousse"; import { EmojiPicker, type Locale as EmojiLocale } from "frimousse";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react";
import { charCountClass } from "../utils/charCount.ts";
import { MentionDropdown } from "./MentionDropdown.tsx"; import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts"; import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts"; import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
@@ -29,6 +31,7 @@ interface TextEditorProps {
autoResize?: boolean; autoResize?: boolean;
onSubmit?: () => void; onSubmit?: () => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
maxLength?: number;
} }
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>( export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
@@ -44,9 +47,11 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
autoResize = false, autoResize = false,
onSubmit, onSubmit,
onKeyDown, onKeyDown,
maxLength,
}, },
ref, ref,
) { ) {
const { i18n } = useLingui();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const emojiViewportRef = useRef<HTMLDivElement>(null); const emojiViewportRef = useRef<HTMLDivElement>(null);
const emojiSearchRef = useRef<HTMLInputElement>(null); const emojiSearchRef = useRef<HTMLInputElement>(null);
@@ -205,6 +210,15 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
id={id} id={id}
className={className} className={className}
/> />
{maxLength != null && (
<span
className={`text-editor-count${
charCountClass(value.length, maxLength)
}`}
>
{value.length} / {maxLength}
</span>
)}
{mentionOpen && ( {mentionOpen && (
<MentionDropdown <MentionDropdown
results={mentionResults} results={mentionResults}
@@ -248,6 +262,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
> >
<EmojiPicker.Root <EmojiPicker.Root
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)} onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
locale={i18n.locale as EmojiLocale}
> >
<div className="emoji-picker-search-row"> <div className="emoji-picker-search-row">
<EmojiPicker.Search <EmojiPicker.Search
@@ -270,8 +285,12 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
// frimousse's onFocusCapture can detect it and arm arrow-key nav // frimousse's onFocusCapture can detect it and arm arrow-key nav
tabIndex={-1} tabIndex={-1}
> >
<EmojiPicker.Loading><Trans>Loading</Trans></EmojiPicker.Loading> <EmojiPicker.Loading>
<EmojiPicker.Empty><Trans>No emoji found.</Trans></EmojiPicker.Empty> <Trans>Loading</Trans>
</EmojiPicker.Loading>
<EmojiPicker.Empty>
<Trans>No emoji found.</Trans>
</EmojiPicker.Empty>
<EmojiPicker.List /> <EmojiPicker.List />
</EmojiPicker.Viewport> </EmojiPicker.Viewport>
</EmojiPicker.Root> </EmojiPicker.Root>

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Avatar } from "./Avatar.tsx"; import { Avatar } from "./Avatar.tsx";
import type { User } from "../model.ts"; import type { User } from "../model.ts";

View File

@@ -1,7 +1,9 @@
import { type RefObject, useCallback, useRef, useState } from "react"; import { type RefObject, useCallback, useRef, useState } from "react";
// Trigger: ':' not preceded by a word character, followed by 1+ word chars // Trigger: ':' not preceded by a word character, followed by a letter then
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z0-9_+-]{1,})$/; // optional word chars / '+' / '-'. Requiring a letter first prevents ASCII
// emoticons like ':-' or ':(' from opening the picker.
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z][A-Za-z0-9_+-]*)$/;
interface EmojiTriggerState { interface EmojiTriggerState {
open: boolean; open: boolean;

View File

@@ -5,7 +5,9 @@ type Locale = (typeof SUPPORTED)[number];
function detectLocale(): Locale { function detectLocale(): Locale {
const stored = localStorage.getItem("locale"); const stored = localStorage.getItem("locale");
if (stored && (SUPPORTED as readonly string[]).includes(stored)) return stored as Locale; if (stored && (SUPPORTED as readonly string[]).includes(stored)) {
return stored as Locale;
}
return navigator.language.startsWith("fr") ? "fr" : "en"; return navigator.language.startsWith("fr") ? "fr" : "en";
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -66,7 +66,7 @@ msgstr "← Back to all dumps"
#: src/pages/UserDumps.tsx:61 #: src/pages/UserDumps.tsx:61
#: src/pages/UserPlaylists.tsx:352 #: src/pages/UserPlaylists.tsx:352
#: src/pages/UserUpvoted.tsx:130 #: src/pages/UserUpvoted.tsx:133
msgid "← Back to profile" msgid "← Back to profile"
msgstr "← Back to profile" msgstr "← Back to profile"
@@ -74,7 +74,7 @@ msgstr "← Back to profile"
msgid "+ Invite someone" msgid "+ Invite someone"
msgstr "+ Invite someone" msgstr "+ Invite someone"
#: src/components/AppHeader.tsx:63 #: src/components/AppHeader.tsx:67
msgid "+ New" msgid "+ New"
msgstr "+ New" msgstr "+ New"
@@ -83,6 +83,7 @@ msgstr "+ New"
msgid "+ New dump" msgid "+ New dump"
msgstr "+ New dump" msgstr "+ New dump"
#: src/components/NewPlaylistForm.tsx:30
#: src/components/PlaylistMembershipPanel.tsx:72 #: src/components/PlaylistMembershipPanel.tsx:72
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
@@ -146,7 +147,7 @@ msgid "Add email…"
msgstr "Add email…" msgstr "Add email…"
#: src/components/AddToPlaylistModal.tsx:64 #: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:262 #: src/components/DumpCreateModal.tsx:275
msgid "Add to playlist" msgid "Add to playlist"
msgstr "Add to playlist" msgstr "Add to playlist"
@@ -160,7 +161,7 @@ msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
msgstr "All {0, plural, one {# dump} other {# dumps}} loaded." msgstr "All {0, plural, one {# dump} other {# dumps}} loaded."
#. placeholder {0}: votes.length #. placeholder {0}: votes.length
#: src/pages/UserUpvoted.tsx:184 #: src/pages/UserUpvoted.tsx:187
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded." msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded." msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
@@ -177,7 +178,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/CommentThread.tsx:353 #: src/components/CommentThread.tsx:353
#: src/components/CommentThread.tsx:483 #: src/components/CommentThread.tsx:483
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/DumpCreateModal.tsx:394 #: src/components/DumpCreateModal.tsx:408
#: src/components/PlaylistCreateForm.tsx:105 #: src/components/PlaylistCreateForm.tsx:105
#: src/pages/DumpEdit.tsx:288 #: src/pages/DumpEdit.tsx:288
#: src/pages/PlaylistDetail.tsx:672 #: src/pages/PlaylistDetail.tsx:672
@@ -278,7 +279,7 @@ msgstr "Delete this playlist? This cannot be undone."
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (optional)" msgstr "Description (optional)"
#: src/components/DumpCreateModal.tsx:439 #: src/components/DumpCreateModal.tsx:453
msgid "Done" msgid "Done"
msgstr "Done" msgstr "Done"
@@ -290,7 +291,7 @@ msgstr "Drop a file here"
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Drop a replacement here" msgstr "Drop a replacement here"
#: src/components/DumpCreateModal.tsx:405 #: src/components/DumpCreateModal.tsx:419
msgid "Dump it" msgid "Dump it"
msgstr "Dump it" msgstr "Dump it"
@@ -298,7 +299,7 @@ msgstr "Dump it"
#~ msgid "Dump not found" #~ msgid "Dump not found"
#~ msgstr "Dump not found" #~ msgstr "Dump not found"
#: src/components/DumpCreateModal.tsx:416 #: src/components/DumpCreateModal.tsx:430
msgid "Dumped!" msgid "Dumped!"
msgstr "Dumped!" msgstr "Dumped!"
@@ -372,7 +373,7 @@ msgstr "Failed to generate invite"
msgid "Failed to load" msgid "Failed to load"
msgstr "Failed to load" msgstr "Failed to load"
#: src/components/DumpCreateModal.tsx:300 #: src/components/DumpCreateModal.tsx:313
msgid "Failed to post" msgid "Failed to post"
msgstr "Failed to post" msgstr "Failed to post"
@@ -400,15 +401,15 @@ msgstr "Failed to save edit"
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Failed to update avatar" msgstr "Failed to update avatar"
#: src/components/DumpCreateModal.tsx:333 #: src/components/DumpCreateModal.tsx:347
msgid "Fetching preview…" msgid "Fetching preview…"
msgstr "Fetching preview…" msgstr "Fetching preview…"
#: src/components/DumpCreateModal.tsx:403 #: src/components/DumpCreateModal.tsx:417
msgid "Fetching…" msgid "Fetching…"
msgstr "Fetching…" msgstr "Fetching…"
#: src/components/DumpCreateModal.tsx:293 #: src/components/DumpCreateModal.tsx:306
#: src/components/FileDropZone.tsx:31 #: src/components/FileDropZone.tsx:31
msgid "File" msgid "File"
msgstr "File" msgstr "File"
@@ -425,7 +426,7 @@ msgstr "File"
#~ msgid "File too large (max 50 MB)" #~ msgid "File too large (max 50 MB)"
#~ msgstr "File too large (max 50 MB)" #~ msgstr "File too large (max 50 MB)"
#: src/components/DumpCreateModal.tsx:187 #: src/components/DumpCreateModal.tsx:200
msgid "File too large (max 50 MB)." msgid "File too large (max 50 MB)."
msgstr "File too large (max 50 MB)." msgstr "File too large (max 50 MB)."
@@ -442,11 +443,11 @@ msgstr "Follow {targetUsername}"
msgid "Follow playlist" msgid "Follow playlist"
msgstr "Follow playlist" msgstr "Follow playlist"
#: src/pages/index/FollowedFeed.tsx:359 #: src/pages/index/FollowedFeed.tsx:358
msgid "Follow some public playlists to see their dumps here." msgid "Follow some public playlists to see their dumps here."
msgstr "Follow some public playlists to see their dumps here." msgstr "Follow some public playlists to see their dumps here."
#: src/pages/index/FollowedFeed.tsx:345 #: src/pages/index/FollowedFeed.tsx:344
msgid "Follow some users to see their dumps here." msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here."
@@ -469,11 +470,11 @@ msgstr "Following"
#~ msgid "Forbidden" #~ msgid "Forbidden"
#~ msgstr "Forbidden" #~ msgstr "Forbidden"
#: src/pages/index/FollowedFeed.tsx:325 #: src/pages/index/FollowedFeed.tsx:324
msgid "From people" msgid "From people"
msgstr "From people" msgstr "From people"
#: src/pages/index/FollowedFeed.tsx:332 #: src/pages/index/FollowedFeed.tsx:331
msgid "From playlists" msgid "From playlists"
msgstr "From playlists" msgstr "From playlists"
@@ -522,7 +523,7 @@ msgstr "just now"
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/AppHeader.tsx:79 #: src/components/AppHeader.tsx:83
msgid "Live updates unavailable." msgid "Live updates unavailable."
msgstr "Live updates unavailable." msgstr "Live updates unavailable."
@@ -543,7 +544,7 @@ msgstr "Loading dump…"
#: src/pages/UserDumps.tsx:111 #: src/pages/UserDumps.tsx:111
#: src/pages/UserPlaylists.tsx:409 #: src/pages/UserPlaylists.tsx:409
#: src/pages/UserPlaylists.tsx:436 #: src/pages/UserPlaylists.tsx:436
#: src/pages/UserUpvoted.tsx:180 #: src/pages/UserUpvoted.tsx:183
msgid "Loading more…" msgid "Loading more…"
msgstr "Loading more…" msgstr "Loading more…"
@@ -565,11 +566,11 @@ msgstr "Loading profile…"
#: src/pages/Notifications.tsx:386 #: src/pages/Notifications.tsx:386
#: src/pages/UserDumps.tsx:50 #: src/pages/UserDumps.tsx:50
#: src/pages/UserPlaylists.tsx:341 #: src/pages/UserPlaylists.tsx:341
#: src/pages/UserUpvoted.tsx:119 #: src/pages/UserUpvoted.tsx:122
msgid "Loading…" msgid "Loading…"
msgstr "Loading…" msgstr "Loading…"
#: src/components/AppHeader.tsx:70 #: src/components/AppHeader.tsx:74
#: src/pages/UserLogin.tsx:62 #: src/pages/UserLogin.tsx:62
#: src/pages/UserLogin.tsx:91 #: src/pages/UserLogin.tsx:91
msgid "Log in" msgid "Log in"
@@ -600,10 +601,14 @@ msgstr "new"
msgid "New" msgid "New"
msgstr "New" msgstr "New"
#: src/components/DumpCreateModal.tsx:262 #: src/components/DumpCreateModal.tsx:275
msgid "New dump" msgid "New dump"
msgstr "New dump" msgstr "New dump"
#: src/components/NewPlaylistForm.tsx:34
msgid "New playlist"
msgstr "New playlist"
#: src/pages/PlaylistDetail.tsx:783 #: src/pages/PlaylistDetail.tsx:783
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet." msgstr "No dumps in this playlist yet."
@@ -647,8 +652,8 @@ msgstr "No users match \"{q}\"."
#: src/pages/Notifications.tsx:327 #: src/pages/Notifications.tsx:327
#: src/pages/UserDumps.tsx:92 #: src/pages/UserDumps.tsx:92
#: src/pages/UserPublicProfile.tsx:930 #: src/pages/UserPublicProfile.tsx:930
#: src/pages/UserPublicProfile.tsx:1049 #: src/pages/UserPublicProfile.tsx:1047
#: src/pages/UserUpvoted.tsx:151 #: src/pages/UserUpvoted.tsx:154
msgid "Nothing here yet." msgid "Nothing here yet."
msgstr "Nothing here yet." msgstr "Nothing here yet."
@@ -690,7 +695,7 @@ msgstr "Password (min. {0} characters)"
#~ msgid "Playlist not found" #~ msgid "Playlist not found"
#~ msgstr "Playlist not found" #~ msgstr "Playlist not found"
#: src/components/AppHeader.tsx:46 #: src/components/AppHeader.tsx:50
#: src/components/UserMenu.tsx:62 #: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:175 #: src/pages/Search.tsx:175
#: src/pages/UserPlaylists.tsx:366 #: src/pages/UserPlaylists.tsx:366
@@ -703,7 +708,7 @@ msgstr "Playlists"
msgid "Playlists ({0}{1})" msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})" msgstr "Playlists ({0}{1})"
#: src/components/DumpCreateModal.tsx:180 #: src/components/DumpCreateModal.tsx:193
msgid "Please select a file." msgid "Please select a file."
msgstr "Please select a file." msgstr "Please select a file."
@@ -728,7 +733,7 @@ msgstr "Posting…"
msgid "private" msgid "private"
msgstr "private" msgstr "private"
#: src/components/DumpCreateModal.tsx:383 #: src/components/DumpCreateModal.tsx:397
#: src/components/PlaylistCreateForm.tsx:94 #: src/components/PlaylistCreateForm.tsx:94
#: src/pages/DumpEdit.tsx:274 #: src/pages/DumpEdit.tsx:274
#: src/pages/PlaylistDetail.tsx:737 #: src/pages/PlaylistDetail.tsx:737
@@ -740,7 +745,7 @@ msgstr "Private"
msgid "public" msgid "public"
msgstr "public" msgstr "public"
#: src/components/DumpCreateModal.tsx:375 #: src/components/DumpCreateModal.tsx:389
#: src/components/PlaylistCreateForm.tsx:87 #: src/components/PlaylistCreateForm.tsx:87
#: src/pages/DumpEdit.tsx:267 #: src/pages/DumpEdit.tsx:267
#: src/pages/PlaylistDetail.tsx:730 #: src/pages/PlaylistDetail.tsx:730
@@ -820,7 +825,7 @@ msgstr "Search failed"
msgid "Searching…" msgid "Searching…"
msgstr "Searching…" msgstr "Searching…"
#: src/components/AppHeader.tsx:61 #: src/components/AppHeader.tsx:65
msgid "Server unreachable" msgid "Server unreachable"
msgstr "Server unreachable" msgstr "Server unreachable"
@@ -836,7 +841,7 @@ msgstr "Submit search"
msgid "Tell people about yourself…" msgid "Tell people about yourself…"
msgstr "Tell people about yourself…" msgstr "Tell people about yourself…"
#: src/components/DumpCreateModal.tsx:363 #: src/components/DumpCreateModal.tsx:377
#: src/pages/DumpEdit.tsx:256 #: src/pages/DumpEdit.tsx:256
msgid "Tell the community what makes this worth their time..." msgid "Tell the community what makes this worth their time..."
msgstr "Tell the community what makes this worth their time..." msgstr "Tell the community what makes this worth their time..."
@@ -877,11 +882,11 @@ msgstr "Unfollow playlist"
msgid "Upload failed" msgid "Upload failed"
msgstr "Upload failed" msgstr "Upload failed"
#: src/components/DumpCreateModal.tsx:404 #: src/components/DumpCreateModal.tsx:418
msgid "Uploading…" msgid "Uploading…"
msgstr "Uploading…" msgstr "Uploading…"
#: src/pages/UserUpvoted.tsx:147 #: src/pages/UserUpvoted.tsx:150
msgid "Upvoted" msgid "Upvoted"
msgstr "Upvoted" msgstr "Upvoted"
@@ -891,12 +896,12 @@ msgstr "Upvoted"
msgid "Upvoted ({0}{1})" msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:309 #: src/components/DumpCreateModal.tsx:322
#: src/pages/DumpEdit.tsx:221 #: src/pages/DumpEdit.tsx:221
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
#: src/components/DumpCreateModal.tsx:164 #: src/components/DumpCreateModal.tsx:176
msgid "URL is required." msgid "URL is required."
msgstr "URL is required." msgstr "URL is required."
@@ -923,15 +928,15 @@ msgstr "Users"
#: src/pages/UserPublicProfile.tsx:878 #: src/pages/UserPublicProfile.tsx:878
#: src/pages/UserPublicProfile.tsx:948 #: src/pages/UserPublicProfile.tsx:948
#: src/pages/UserPublicProfile.tsx:1076 #: src/pages/UserPublicProfile.tsx:1074
msgid "View all →" msgid "View all →"
msgstr "View all →" msgstr "View all →"
#: src/components/DumpCreateModal.tsx:418 #: src/components/DumpCreateModal.tsx:432
msgid "View dump →" msgid "View dump →"
msgstr "View dump →" msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:356 #: src/components/DumpCreateModal.tsx:370
#: src/pages/DumpEdit.tsx:250 #: src/pages/DumpEdit.tsx:250
msgid "Why are you dumping this?" msgid "Why are you dumping this?"
msgstr "Why are you dumping this?" msgstr "Why are you dumping this?"

View File

@@ -7,6 +7,11 @@ msgstr ""
"X-Generator: @lingui/cli\n" "X-Generator: @lingui/cli\n"
"Language: fr\n" "Language: fr\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
#: src/components/CommentThread.tsx:170 #: src/components/CommentThread.tsx:170
msgid "[deleted]" msgid "[deleted]"
@@ -61,7 +66,7 @@ msgstr "← Retour à toutes les recos"
#: src/pages/UserDumps.tsx:61 #: src/pages/UserDumps.tsx:61
#: src/pages/UserPlaylists.tsx:352 #: src/pages/UserPlaylists.tsx:352
#: src/pages/UserUpvoted.tsx:130 #: src/pages/UserUpvoted.tsx:133
msgid "← Back to profile" msgid "← Back to profile"
msgstr "← Retour au profil" msgstr "← Retour au profil"
@@ -69,7 +74,7 @@ msgstr "← Retour au profil"
msgid "+ Invite someone" msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un" msgstr "+ Inviter quelqu'un"
#: src/components/AppHeader.tsx:63 #: src/components/AppHeader.tsx:67
msgid "+ New" msgid "+ New"
msgstr "+ Nouveau" msgstr "+ Nouveau"
@@ -78,6 +83,7 @@ msgstr "+ Nouveau"
msgid "+ New dump" msgid "+ New dump"
msgstr "+ Nouvelle reco" msgstr "+ Nouvelle reco"
#: src/components/NewPlaylistForm.tsx:30
#: src/components/PlaylistMembershipPanel.tsx:72 #: src/components/PlaylistMembershipPanel.tsx:72
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ Nouvelle collection" msgstr "+ Nouvelle collection"
@@ -141,7 +147,7 @@ msgid "Add email…"
msgstr "Ajouter un e-mail…" msgstr "Ajouter un e-mail…"
#: src/components/AddToPlaylistModal.tsx:64 #: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:262 #: src/components/DumpCreateModal.tsx:275
msgid "Add to playlist" msgid "Add to playlist"
msgstr "Ajouter à la collection" msgstr "Ajouter à la collection"
@@ -151,7 +157,7 @@ msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
msgstr "Toutes les {0, plural, one {# reco} other {# recos}} chargées." msgstr "Toutes les {0, plural, one {# reco} other {# recos}} chargées."
#. placeholder {0}: votes.length #. placeholder {0}: votes.length
#: src/pages/UserUpvoted.tsx:184 #: src/pages/UserUpvoted.tsx:187
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded." msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} chargées." msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} chargées."
@@ -168,7 +174,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/CommentThread.tsx:353 #: src/components/CommentThread.tsx:353
#: src/components/CommentThread.tsx:483 #: src/components/CommentThread.tsx:483
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/DumpCreateModal.tsx:394 #: src/components/DumpCreateModal.tsx:408
#: src/components/PlaylistCreateForm.tsx:105 #: src/components/PlaylistCreateForm.tsx:105
#: src/pages/DumpEdit.tsx:288 #: src/pages/DumpEdit.tsx:288
#: src/pages/PlaylistDetail.tsx:672 #: src/pages/PlaylistDetail.tsx:672
@@ -261,7 +267,7 @@ msgstr "Supprimer cette collection ? Cette action est irréversible."
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (facultatif)" msgstr "Description (facultatif)"
#: src/components/DumpCreateModal.tsx:439 #: src/components/DumpCreateModal.tsx:453
msgid "Done" msgid "Done"
msgstr "Terminé" msgstr "Terminé"
@@ -273,11 +279,11 @@ msgstr "Déposez un fichier ici"
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici" msgstr "Déposez un fichier de remplacement ici"
#: src/components/DumpCreateModal.tsx:405 #: src/components/DumpCreateModal.tsx:419
msgid "Dump it" msgid "Dump it"
msgstr "Recommander" msgstr "Recommander"
#: src/components/DumpCreateModal.tsx:416 #: src/components/DumpCreateModal.tsx:430
msgid "Dumped!" msgid "Dumped!"
msgstr "Recommandé !" msgstr "Recommandé !"
@@ -351,7 +357,7 @@ msgstr "Impossible de générer une invitation"
msgid "Failed to load" msgid "Failed to load"
msgstr "Chargement échoué" msgstr "Chargement échoué"
#: src/components/DumpCreateModal.tsx:300 #: src/components/DumpCreateModal.tsx:313
msgid "Failed to post" msgid "Failed to post"
msgstr "Publication échouée" msgstr "Publication échouée"
@@ -379,20 +385,20 @@ msgstr "Impossible d'enregistrer la modification"
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar" msgstr "Impossible de mettre à jour l'avatar"
#: src/components/DumpCreateModal.tsx:333 #: src/components/DumpCreateModal.tsx:347
msgid "Fetching preview…" msgid "Fetching preview…"
msgstr "Récupération de l'aperçu…" msgstr "Récupération de l'aperçu…"
#: src/components/DumpCreateModal.tsx:403 #: src/components/DumpCreateModal.tsx:417
msgid "Fetching…" msgid "Fetching…"
msgstr "Récupération…" msgstr "Récupération…"
#: src/components/DumpCreateModal.tsx:293 #: src/components/DumpCreateModal.tsx:306
#: src/components/FileDropZone.tsx:31 #: src/components/FileDropZone.tsx:31
msgid "File" msgid "File"
msgstr "Fichier" msgstr "Fichier"
#: src/components/DumpCreateModal.tsx:187 #: src/components/DumpCreateModal.tsx:200
msgid "File too large (max 50 MB)." msgid "File too large (max 50 MB)."
msgstr "Fichier trop volumineux (max 50 Mo)." msgstr "Fichier trop volumineux (max 50 Mo)."
@@ -409,11 +415,11 @@ msgstr "Suivre {targetUsername}"
msgid "Follow playlist" msgid "Follow playlist"
msgstr "Suivre la collection" msgstr "Suivre la collection"
#: src/pages/index/FollowedFeed.tsx:359 #: src/pages/index/FollowedFeed.tsx:358
msgid "Follow some public playlists to see their dumps here." msgid "Follow some public playlists to see their dumps here."
msgstr "Suivez des collections publiques pour voir leurs recos ici." msgstr "Suivez des collections publiques pour voir leurs recos ici."
#: src/pages/index/FollowedFeed.tsx:345 #: src/pages/index/FollowedFeed.tsx:344
msgid "Follow some users to see their dumps here." msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici." msgstr "Suivez des utilisateurs pour voir leurs recos ici."
@@ -432,11 +438,11 @@ msgstr "Suivies ({0}{1})"
msgid "Following" msgid "Following"
msgstr "Abonné" msgstr "Abonné"
#: src/pages/index/FollowedFeed.tsx:325 #: src/pages/index/FollowedFeed.tsx:324
msgid "From people" msgid "From people"
msgstr "De personnes" msgstr "De personnes"
#: src/pages/index/FollowedFeed.tsx:332 #: src/pages/index/FollowedFeed.tsx:331
msgid "From playlists" msgid "From playlists"
msgstr "De collections" msgstr "De collections"
@@ -464,7 +470,7 @@ msgstr "à l'instant"
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/AppHeader.tsx:79 #: src/components/AppHeader.tsx:83
msgid "Live updates unavailable." msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles." msgstr "Mises à jour en direct indisponibles."
@@ -485,7 +491,7 @@ msgstr "Chargement de la reco…"
#: src/pages/UserDumps.tsx:111 #: src/pages/UserDumps.tsx:111
#: src/pages/UserPlaylists.tsx:409 #: src/pages/UserPlaylists.tsx:409
#: src/pages/UserPlaylists.tsx:436 #: src/pages/UserPlaylists.tsx:436
#: src/pages/UserUpvoted.tsx:180 #: src/pages/UserUpvoted.tsx:183
msgid "Loading more…" msgid "Loading more…"
msgstr "Chargement…" msgstr "Chargement…"
@@ -507,11 +513,11 @@ msgstr "Chargement du profil…"
#: src/pages/Notifications.tsx:386 #: src/pages/Notifications.tsx:386
#: src/pages/UserDumps.tsx:50 #: src/pages/UserDumps.tsx:50
#: src/pages/UserPlaylists.tsx:341 #: src/pages/UserPlaylists.tsx:341
#: src/pages/UserUpvoted.tsx:119 #: src/pages/UserUpvoted.tsx:122
msgid "Loading…" msgid "Loading…"
msgstr "Chargement…" msgstr "Chargement…"
#: src/components/AppHeader.tsx:70 #: src/components/AppHeader.tsx:74
#: src/pages/UserLogin.tsx:62 #: src/pages/UserLogin.tsx:62
#: src/pages/UserLogin.tsx:91 #: src/pages/UserLogin.tsx:91
msgid "Log in" msgid "Log in"
@@ -542,10 +548,14 @@ msgstr "nouveau"
msgid "New" msgid "New"
msgstr "Nouveau" msgstr "Nouveau"
#: src/components/DumpCreateModal.tsx:262 #: src/components/DumpCreateModal.tsx:275
msgid "New dump" msgid "New dump"
msgstr "Nouvelle reco" msgstr "Nouvelle reco"
#: src/components/NewPlaylistForm.tsx:34
msgid "New playlist"
msgstr "Nouvelle collection"
#: src/pages/PlaylistDetail.tsx:783 #: src/pages/PlaylistDetail.tsx:783
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant." msgstr "Aucune reco dans cette collection pour l'instant."
@@ -585,8 +595,8 @@ msgstr "Aucun utilisateur ne correspond à « {q} »."
#: src/pages/Notifications.tsx:327 #: src/pages/Notifications.tsx:327
#: src/pages/UserDumps.tsx:92 #: src/pages/UserDumps.tsx:92
#: src/pages/UserPublicProfile.tsx:930 #: src/pages/UserPublicProfile.tsx:930
#: src/pages/UserPublicProfile.tsx:1049 #: src/pages/UserPublicProfile.tsx:1047
#: src/pages/UserUpvoted.tsx:151 #: src/pages/UserUpvoted.tsx:154
msgid "Nothing here yet." msgid "Nothing here yet."
msgstr "Rien ici pour l'instant." msgstr "Rien ici pour l'instant."
@@ -616,7 +626,7 @@ msgstr "Mot de passe"
msgid "Password (min. {0} characters)" msgid "Password (min. {0} characters)"
msgstr "Mot de passe (min. {0} caractères)" msgstr "Mot de passe (min. {0} caractères)"
#: src/components/AppHeader.tsx:46 #: src/components/AppHeader.tsx:50
#: src/components/UserMenu.tsx:62 #: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:175 #: src/pages/Search.tsx:175
#: src/pages/UserPlaylists.tsx:366 #: src/pages/UserPlaylists.tsx:366
@@ -629,7 +639,7 @@ msgstr "Collections"
msgid "Playlists ({0}{1})" msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})" msgstr "Collections ({0}{1})"
#: src/components/DumpCreateModal.tsx:180 #: src/components/DumpCreateModal.tsx:193
msgid "Please select a file." msgid "Please select a file."
msgstr "Veuillez sélectionner un fichier." msgstr "Veuillez sélectionner un fichier."
@@ -654,7 +664,7 @@ msgstr "Publication…"
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
#: src/components/DumpCreateModal.tsx:383 #: src/components/DumpCreateModal.tsx:397
#: src/components/PlaylistCreateForm.tsx:94 #: src/components/PlaylistCreateForm.tsx:94
#: src/pages/DumpEdit.tsx:274 #: src/pages/DumpEdit.tsx:274
#: src/pages/PlaylistDetail.tsx:737 #: src/pages/PlaylistDetail.tsx:737
@@ -666,7 +676,7 @@ msgstr "Privé"
msgid "public" msgid "public"
msgstr "public" msgstr "public"
#: src/components/DumpCreateModal.tsx:375 #: src/components/DumpCreateModal.tsx:389
#: src/components/PlaylistCreateForm.tsx:87 #: src/components/PlaylistCreateForm.tsx:87
#: src/pages/DumpEdit.tsx:267 #: src/pages/DumpEdit.tsx:267
#: src/pages/PlaylistDetail.tsx:730 #: src/pages/PlaylistDetail.tsx:730
@@ -746,7 +756,7 @@ msgstr "Recherche échouée"
msgid "Searching…" msgid "Searching…"
msgstr "Recherche…" msgstr "Recherche…"
#: src/components/AppHeader.tsx:61 #: src/components/AppHeader.tsx:65
msgid "Server unreachable" msgid "Server unreachable"
msgstr "Serveur inaccessible" msgstr "Serveur inaccessible"
@@ -762,7 +772,7 @@ msgstr "Lancer la recherche"
msgid "Tell people about yourself…" msgid "Tell people about yourself…"
msgstr "Parlez de vous…" msgstr "Parlez de vous…"
#: src/components/DumpCreateModal.tsx:363 #: src/components/DumpCreateModal.tsx:377
#: src/pages/DumpEdit.tsx:256 #: src/pages/DumpEdit.tsx:256
msgid "Tell the community what makes this worth their time..." msgid "Tell the community what makes this worth their time..."
msgstr "Dites à la communauté pourquoi ça vaut le coup…" msgstr "Dites à la communauté pourquoi ça vaut le coup…"
@@ -799,11 +809,11 @@ msgstr "Ne plus suivre la collection"
msgid "Upload failed" msgid "Upload failed"
msgstr "Envoi échoué" msgstr "Envoi échoué"
#: src/components/DumpCreateModal.tsx:404 #: src/components/DumpCreateModal.tsx:418
msgid "Uploading…" msgid "Uploading…"
msgstr "Envoi…" msgstr "Envoi…"
#: src/pages/UserUpvoted.tsx:147 #: src/pages/UserUpvoted.tsx:150
msgid "Upvoted" msgid "Upvoted"
msgstr "Voté" msgstr "Voté"
@@ -813,12 +823,12 @@ msgstr "Voté"
msgid "Upvoted ({0}{1})" msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})" msgstr "Votés ({0}{1})"
#: src/components/DumpCreateModal.tsx:309 #: src/components/DumpCreateModal.tsx:322
#: src/pages/DumpEdit.tsx:221 #: src/pages/DumpEdit.tsx:221
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
#: src/components/DumpCreateModal.tsx:164 #: src/components/DumpCreateModal.tsx:176
msgid "URL is required." msgid "URL is required."
msgstr "L'URL est obligatoire." msgstr "L'URL est obligatoire."
@@ -837,15 +847,15 @@ msgstr "Utilisateurs"
#: src/pages/UserPublicProfile.tsx:878 #: src/pages/UserPublicProfile.tsx:878
#: src/pages/UserPublicProfile.tsx:948 #: src/pages/UserPublicProfile.tsx:948
#: src/pages/UserPublicProfile.tsx:1076 #: src/pages/UserPublicProfile.tsx:1074
msgid "View all →" msgid "View all →"
msgstr "Tout voir →" msgstr "Tout voir →"
#: src/components/DumpCreateModal.tsx:418 #: src/components/DumpCreateModal.tsx:432
msgid "View dump →" msgid "View dump →"
msgstr "Voir la reco →" msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:356 #: src/components/DumpCreateModal.tsx:370
#: src/pages/DumpEdit.tsx:250 #: src/pages/DumpEdit.tsx:250
msgid "Why are you dumping this?" msgid "Why are you dumping this?"
msgstr "Pourquoi recommandez-vous ça ?" msgstr "Pourquoi recommandez-vous ça ?"

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router"; import { Link, useLocation, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { dumpUrl } from "../utils/urls.ts"; import { dumpUrl } from "../utils/urls.ts";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx"; import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
@@ -190,7 +190,9 @@ export function Dump() {
if (dumpState.status === "loading") { if (dumpState.status === "loading") {
return ( return (
<PageShell> <PageShell>
<p className="page-loading"><Trans>Loading dump</Trans></p> <p className="page-loading">
<Trans>Loading dump</Trans>
</p>
</PageShell> </PageShell>
); );
} }
@@ -315,7 +317,9 @@ export function Dump() {
<Trans>Edit</Trans> <Trans>Edit</Trans>
</Link> </Link>
)} )}
<Link to="/"><Trans> Back to all dumps</Trans></Link> <Link to="/">
<Trans> Back to all dumps</Trans>
</Link>
</div> </div>
{/* Comments */} {/* Comments */}

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts"; import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts"; import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts"; import { useRequiredAuth } from "../hooks/useAuth.ts";
@@ -65,7 +65,9 @@ export function DumpEdit() {
}, [selectedDump, token]); }, [selectedDump, token]);
const handleSave = async () => { const handleSave = async () => {
if (state.status !== "loaded") return; if (
state.status !== "loaded" || comment.length > VALIDATION.DUMP_COMMENT_MAX
) return;
let res: Response; let res: Response;
@@ -140,7 +142,9 @@ export function DumpEdit() {
if (state.status === "loading") { if (state.status === "loading") {
return ( return (
<PageShell> <PageShell>
<p className="page-loading"><Trans>Loading dump</Trans></p> <p className="page-loading">
<Trans>Loading dump</Trans>
</p>
</PageShell> </PageShell>
); );
} }
@@ -177,7 +181,9 @@ export function DumpEdit() {
<PageShell> <PageShell>
<div className="form-page form-page--two-col"> <div className="form-page form-page--two-col">
<div className="form-page-header"> <div className="form-page-header">
<p className="form-page-eyebrow"><Trans>Editing</Trans></p> <p className="form-page-eyebrow">
<Trans>Editing</Trans>
</p>
<h1 className="form-page-title">{dump.title}</h1> <h1 className="form-page-title">{dump.title}</h1>
</div> </div>
@@ -203,7 +209,9 @@ export function DumpEdit() {
onClick={handleRefreshMetadata} onClick={handleRefreshMetadata}
disabled={refreshing} disabled={refreshing}
> >
{refreshing ? <Trans>Refreshing</Trans> : <Trans>Refresh metadata</Trans>} {refreshing
? <Trans>Refreshing</Trans>
: <Trans>Refresh metadata</Trans>}
</button> </button>
)} )}
</div> </div>
@@ -218,7 +226,9 @@ export function DumpEdit() {
{dump.kind === "url" {dump.kind === "url"
? ( ? (
<div className="form-group"> <div className="form-group">
<label htmlFor="url"><Trans>URL</Trans></label> <label htmlFor="url">
<Trans>URL</Trans>
</label>
<input <input
id="url" id="url"
type="url" type="url"
@@ -255,6 +265,7 @@ export function DumpEdit() {
onChange={setComment} onChange={setComment}
placeholder={t`Tell the community what makes this worth their time...`} placeholder={t`Tell the community what makes this worth their time...`}
rows={3} rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
/> />
</div> </div>
@@ -287,7 +298,11 @@ export function DumpEdit() {
<Link to={dumpUrl(dump)} className="form-cancel"> <Link to={dumpUrl(dump)} className="form-cancel">
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Link> </Link>
<button type="submit" className="btn-primary"> <button
type="submit"
className="btn-primary"
disabled={comment.length > VALIDATION.DUMP_COMMENT_MAX}
>
<Trans>Save</Trans> <Trans>Save</Trans>
</button> </button>
</div> </div>

View File

@@ -83,9 +83,21 @@ export function Index() {
); );
const mainFetchDone = useRef(false); const mainFetchDone = useRef(false);
const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot"; const searchParams = new URLSearchParams(location.search);
const rawTab = searchParams.get("tab") ?? "hot";
const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot"; const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot";
// Web Share Target: Android share sheet navigates to /?share_url=...
const shareUrl = searchParams.get("share_url") ??
searchParams.get("share_text") ?? "";
useEffect(() => {
if (!shareUrl) return;
// Clean share params from the URL so a refresh doesn't re-open the modal
const clean = tab !== "hot" ? `?tab=${tab}` : "";
globalThis.history.replaceState({}, "", location.pathname + clean);
}, [shareUrl, tab, location.pathname]);
// ── Main feed fetch ── // ── Main feed fetch ──
useEffect(() => { useEffect(() => {
@@ -241,6 +253,7 @@ export function Index() {
</div> </div>
} }
disableNew={dumpsState.status === "error"} disableNew={dumpsState.status === "error"}
initialDumpUrl={shareUrl || undefined}
/> />
<div className="index-below-header"> <div className="index-below-header">

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts"; import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
@@ -315,7 +315,9 @@ export function Notifications() {
</div> </div>
{state.status === "loading" && ( {state.status === "loading" && (
<p className="page-loading"><Trans>Loading</Trans></p> <p className="page-loading">
<Trans>Loading</Trans>
</p>
)} )}
{state.status === "error" && ( {state.status === "error" && (
<ErrorCard title={t`Failed to load`} message={state.error} /> <ErrorCard title={t`Failed to load`} message={state.error} />
@@ -324,7 +326,9 @@ export function Notifications() {
{state.status === "loaded" && state.items.length === 0 && ( {state.status === "loaded" && state.items.length === 0 && (
<div className="notifications-empty"> <div className="notifications-empty">
<span className="notifications-empty-icon">🔕</span> <span className="notifications-empty-icon">🔕</span>
<p><Trans>Nothing here yet.</Trans></p> <p>
<Trans>Nothing here yet.</Trans>
</p>
<p className="notifications-empty-hint"> <p className="notifications-empty-hint">
<Trans> <Trans>
You'll be notified when someone follows your playlists, upvotes You'll be notified when someone follows your playlists, upvotes
@@ -338,7 +342,11 @@ export function Notifications() {
groupByDate(state.items).map(({ label, items }) => ( groupByDate(state.items).map(({ label, items }) => (
<section key={label} className="notif-group"> <section key={label} className="notif-group">
<h2 className="notif-group-label"> <h2 className="notif-group-label">
{label === "Today" ? t`Today` : label === "Yesterday" ? t`Yesterday` : t`Earlier`} {label === "Today"
? t`Today`
: label === "Yesterday"
? t`Yesterday`
: t`Earlier`}
</h2> </h2>
<ul className="notification-list"> <ul className="notification-list">
{items.map((n) => ( {items.map((n) => (
@@ -383,7 +391,9 @@ export function Notifications() {
onClick={loadMore} onClick={loadMore}
disabled={state.loadingMore} disabled={state.loadingMore}
> >
{state.loadingMore ? <Trans>Loading</Trans> : <Trans>Load more</Trans>} {state.loadingMore
? <Trans>Loading</Trans>
: <Trans>Load more</Trans>}
</button> </button>
)} )}
</div> </div>

View File

@@ -6,9 +6,10 @@ import {
useState, useState,
} from "react"; } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "../components/CountedInput.tsx";
import type { import type {
PlaylistWithDumps, PlaylistWithDumps,
RawDump, RawDump,
@@ -527,7 +528,10 @@ export function PlaylistDetail() {
}; };
const handleEditSave = async () => { const handleEditSave = async () => {
if (!playlistId || state.status !== "loaded") return; if (
!playlistId || state.status !== "loaded" ||
editDescription.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return;
setEditSaving(true); setEditSaving(true);
setEditError(null); setEditError(null);
try { try {
@@ -587,7 +591,9 @@ export function PlaylistDetail() {
if (state.status === "loading") { if (state.status === "loading") {
return ( return (
<PageShell> <PageShell>
<p className="page-loading"><Trans>Loading playlist</Trans></p> <p className="page-loading">
<Trans>Loading playlist</Trans>
</p>
</PageShell> </PageShell>
); );
} }
@@ -649,17 +655,19 @@ export function PlaylistDetail() {
{editOpen {editOpen
? ( ? (
<div className="playlist-detail-title-row"> <div className="playlist-detail-title-row">
<input <CountedInput
type="text"
className="playlist-edit-input" className="playlist-edit-input"
value={editTitle} value={editTitle}
onChange={(e) => setEditTitle(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
autoFocus autoFocus
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
/> />
<button <button
type="button" type="button"
className="btn-primary" className="btn-primary"
disabled={editSaving} disabled={editSaving ||
editDescription.length >
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
onClick={handleEditSave} onClick={handleEditSave}
> >
{editSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>} {editSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
@@ -710,6 +718,7 @@ export function PlaylistDetail() {
placeholder={t`Description (optional)`} placeholder={t`Description (optional)`}
autoResize autoResize
rows={1} rows={1}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/> />
) )
: playlist.description && ( : playlist.description && (
@@ -745,7 +754,9 @@ export function PlaylistDetail() {
playlist.isPublic ? "" : " playlist-badge--private" playlist.isPublic ? "" : " playlist-badge--private"
}`} }`}
> >
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>} {playlist.isPublic
? <Trans>public</Trans>
: <Trans>private</Trans>}
</span> </span>
{playlist.ownerUsername && ( {playlist.ownerUsername && (
<Link <Link
@@ -765,7 +776,9 @@ export function PlaylistDetail() {
text={t`Edited ${playlist.updatedAt.toLocaleString()}`} text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
> >
<span className="playlist-edited-label"> <span className="playlist-edited-label">
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans> <Trans>
edited {relativeTime(playlist.updatedAt)}
</Trans>
</span> </span>
</Tooltip> </Tooltip>
)} )}
@@ -780,13 +793,15 @@ export function PlaylistDetail() {
</div> </div>
{visibleDumps.length === 0 {visibleDumps.length === 0
? <p className="empty-state"><Trans>No dumps in this playlist yet.</Trans></p> ? (
<p className="empty-state">
<Trans>No dumps in this playlist yet.</Trans>
</p>
)
: ( : (
<div <div
className="playlist-dump-list" className="playlist-dump-list"
onDragOver={isOwner onDragOver={isOwner ? (e) => e.preventDefault() : undefined}
? (e) => e.preventDefault()
: undefined}
> >
{visibleDumps.map((dump) => { {visibleDumps.map((dump) => {
const isActive = activeDumpIds.has(dump.id); const isActive = activeDumpIds.has(dump.id);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router"; import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { AppHeader } from "../components/AppHeader.tsx"; import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx"; import { SearchBar } from "../components/SearchBar.tsx";
@@ -203,11 +203,15 @@ export function Search() {
)} )}
{state.status === "idle" && ( {state.status === "idle" && (
<p className="search-page-empty"><Trans>Enter a query to search.</Trans></p> <p className="search-page-empty">
<Trans>Enter a query to search.</Trans>
</p>
)} )}
{state.status === "loading" && ( {state.status === "loading" && (
<p className="search-page-empty"><Trans>Searching</Trans></p> <p className="search-page-empty">
<Trans>Searching</Trans>
</p>
)} )}
{state.status === "error" && ( {state.status === "error" && (
@@ -236,10 +240,14 @@ export function Search() {
)} )}
<div ref={sentinelRef} /> <div ref={sentinelRef} />
{state.dumps.loadingMore && ( {state.dumps.loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p> <p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)} )}
{!state.dumps.hasMore && state.dumps.items.length > 0 && ( {!state.dumps.hasMore && state.dumps.items.length > 0 && (
<p className="feed-end"><Trans>You've reached the end.</Trans></p> <p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)} )}
</> </>
)} )}
@@ -270,7 +278,11 @@ export function Search() {
{state.status === "loaded" && tab === "playlists" && ( {state.status === "loaded" && tab === "playlists" && (
state.playlists.length === 0 state.playlists.length === 0
? <p className="search-page-empty">{t`No playlists match "${q}".`}</p> ? (
<p className="search-page-empty">
{t`No playlists match "${q}".`}
</p>
)
: ( : (
<ul className="dump-feed"> <ul className="dump-feed">
{state.playlists.map((p) => ( {state.playlists.map((p) => (

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans, Plural } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
@@ -47,7 +47,9 @@ export function UserDumps() {
if (state.status === "loading") { if (state.status === "loading") {
return ( return (
<PageShell> <PageShell>
<p className="page-loading"><Trans>Loading</Trans></p> <p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell> </PageShell>
); );
} }
@@ -89,7 +91,11 @@ export function UserDumps() {
)} )}
{dumps.length === 0 {dumps.length === 0
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p> ? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: ( : (
<ul className="dump-feed"> <ul className="dump-feed">
{dumps.map((dump) => ( {dumps.map((dump) => (
@@ -108,10 +114,18 @@ export function UserDumps() {
)} )}
<div ref={sentinelRef} /> <div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>} {loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && dumps.length > 0 && ( {!hasMore && dumps.length > 0 && (
<p className="index-status"> <p className="index-status">
<Trans>All <Plural value={dumps.length} one="# dump" other="# dumps" /> loaded.</Trans> <Trans>
All <Plural value={dumps.length} one="# dump" other="# dumps" />
{" "}
loaded.
</Trans>
</p> </p>
)} )}
</PageShell> </PageShell>

View File

@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import type { SubmitEvent } from "react"; import type { SubmitEvent } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
@@ -59,7 +59,9 @@ export function UserLogin() {
return ( return (
<PageShell centered> <PageShell centered>
<div className="auth-card"> <div className="auth-card">
<h1 className="auth-card-title"><Trans>Log in</Trans></h1> <h1 className="auth-card-title">
<Trans>Log in</Trans>
</h1>
{state.status === "error" && ( {state.status === "error" && (
<ErrorCard title={t`Login failed`} message={state.error} /> <ErrorCard title={t`Login failed`} message={state.error} />

View File

@@ -6,7 +6,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
@@ -338,7 +338,9 @@ export function UserPlaylists() {
if (state.status === "loading") { if (state.status === "loading") {
return ( return (
<PageShell> <PageShell>
<p className="page-loading"><Trans>Loading</Trans></p> <p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell> </PageShell>
); );
} }
@@ -384,12 +386,17 @@ export function UserPlaylists() {
<div className="profile-section-header"> <div className="profile-section-header">
<h2 className="profile-section-title"> <h2 className="profile-section-title">
<Trans> <Trans>
Created ({created.items.length}{created.hasMore ? "+" : ""}) Created ({created.items.length}
{created.hasMore ? "+" : ""})
</Trans> </Trans>
</h2> </h2>
</div> </div>
{created.items.length === 0 {created.items.length === 0
? <p className="empty-state"><Trans>No playlists yet.</Trans></p> ? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: ( : (
<ul className="dump-feed"> <ul className="dump-feed">
{created.items.map((p) => ( {created.items.map((p) => (
@@ -406,7 +413,9 @@ export function UserPlaylists() {
)} )}
<div ref={createdSentinelRef} /> <div ref={createdSentinelRef} />
{created.loadingMore && ( {created.loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p> <p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)} )}
</section> </section>
@@ -414,7 +423,8 @@ export function UserPlaylists() {
<div className="profile-section-header"> <div className="profile-section-header">
<h2 className="profile-section-title"> <h2 className="profile-section-title">
<Trans> <Trans>
Followed ({followed.items.length}{followed.hasMore ? "+" : ""}) Followed ({followed.items.length}
{followed.hasMore ? "+" : ""})
</Trans> </Trans>
</h2> </h2>
</div> </div>
@@ -433,7 +443,9 @@ export function UserPlaylists() {
)} )}
<div ref={followedSentinelRef} /> <div ref={followedSentinelRef} />
{followed.loadingMore && ( {followed.loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p> <p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)} )}
</section> </section>

View File

@@ -6,10 +6,10 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE, VALIDATION } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts"; import type { Dump, PaginatedData, PublicUser } from "../model.ts";
import { import {
deserializeAuthResponse, deserializeAuthResponse,
@@ -89,7 +89,9 @@ function InviteButton() {
<button type="button" className="invite-btn" onClick={generate}> <button type="button" className="invite-btn" onClick={generate}>
<Trans>+ Invite someone</Trans> <Trans>+ Invite someone</Trans>
</button> </button>
{error && <ErrorCard title={t`Failed to generate invite`} message={error} />} {error && (
<ErrorCard title={t`Failed to generate invite`} message={error} />
)}
</div> </div>
); );
} }
@@ -550,7 +552,10 @@ export function UserPublicProfile() {
}; };
const handleDescSave = async () => { const handleDescSave = async () => {
if (state.status !== "loaded") return; if (
state.status !== "loaded" ||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX
) return;
setDescSaving(true); setDescSaving(true);
setDescError(null); setDescError(null);
try { try {
@@ -587,7 +592,9 @@ export function UserPublicProfile() {
if (state.status === "loading") { if (state.status === "loading") {
return ( return (
<PageShell> <PageShell>
<p className="page-loading"><Trans>Loading profile</Trans></p> <p className="page-loading">
<Trans>Loading profile</Trans>
</p>
</PageShell> </PageShell>
); );
} }
@@ -689,7 +696,9 @@ export function UserPublicProfile() {
className="profile-email-btn profile-email-btn--save" className="profile-email-btn profile-email-btn--save"
disabled={emailSaving || !emailDraft.trim()} disabled={emailSaving || !emailDraft.trim()}
> >
{emailSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>} {emailSaving
? <Trans>Saving</Trans>
: <Trans>Save</Trans>}
</button> </button>
<button <button
type="button" type="button"
@@ -723,7 +732,10 @@ export function UserPublicProfile() {
) )
)} )}
{avatarError && ( {avatarError && (
<ErrorCard title={t`Failed to update avatar`} message={avatarError} /> <ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)} )}
{!isOwnProfile && ( {!isOwnProfile && (
<FollowUserButton <FollowUserButton
@@ -754,13 +766,15 @@ export function UserPublicProfile() {
onSubmit={handleDescSave} onSubmit={handleDescSave}
placeholder={t`Tell people about yourself…`} placeholder={t`Tell people about yourself…`}
autoResize autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/> />
<div className="profile-description-actions"> <div className="profile-description-actions">
<button <button
type="button" type="button"
className="btn-primary" className="btn-primary"
onClick={handleDescSave} onClick={handleDescSave}
disabled={descSaving} disabled={descSaving ||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX}
> >
{descSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>} {descSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button> </button>
@@ -842,7 +856,10 @@ export function UserPublicProfile() {
<section className="profile-section" id="playlists"> <section className="profile-section" id="playlists">
<div className="profile-section-header"> <div className="profile-section-header">
<h2 className="profile-section-title"> <h2 className="profile-section-title">
<Trans>Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})</Trans> <Trans>
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</Trans>
</h2> </h2>
{isOwnProfile && ( {isOwnProfile && (
<NewPlaylistForm <NewPlaylistForm
@@ -862,7 +879,11 @@ export function UserPublicProfile() {
)} )}
</div> </div>
{playlists.items.length === 0 {playlists.items.length === 0
? <p className="empty-state"><Trans>No playlists yet.</Trans></p> ? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: ( : (
<ul className="dump-feed"> <ul className="dump-feed">
{playlists.items.map((p) => ( {playlists.items.map((p) => (
@@ -927,7 +948,11 @@ function DumpList(
<DumpCreateModal onClose={() => setCreateModalOpen(false)} /> <DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)} )}
{dumps.length === 0 {dumps.length === 0
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p> ? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: ( : (
<ul className="dump-feed"> <ul className="dump-feed">
{dumps.map((dump) => ( {dumps.map((dump) => (
@@ -945,7 +970,9 @@ function DumpList(
</ul> </ul>
)} )}
{dumps.length > 0 && ( {dumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all"><Trans>View all </Trans></Link> <Link to={viewAllHref} className="profile-view-all">
<Trans>View all </Trans>
</Link>
)} )}
</section> </section>
); );
@@ -998,9 +1025,7 @@ function UpvotedDumpList(
useEffect(() => { useEffect(() => {
if (!profileUserId || !isOwnProfile) return; if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) { if (prevMyVotesRef.current === null) {
// setVotedIds must fire here alongside prevMyVotesRef mutation; render-phase // setVotedIds + prevMyVotesRef must be co-located to stay consistent.
// isn't possible because startFading/cancelFading (below) are also setState
// calls that cannot be invoked during render.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(wsMyVotes)); setVotedIds(new Set(wsMyVotes));
prevMyVotesRef.current = new Set(wsMyVotes); prevMyVotesRef.current = new Set(wsMyVotes);
@@ -1021,8 +1046,8 @@ function UpvotedDumpList(
const { dumpId, voterId, action } = lastVoteEvent; const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return; if (voterId !== profileUserId) return;
if (action === "remove") { if (action === "remove") {
// setVotedIds + startFading must be coordinated in the same effect body // setVotedIds and startFading must fire together to avoid a render with
// to guarantee a single render — render-phase can't call startFading (setState). // stale votedIds between the two updates.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds((prev) => { setVotedIds((prev) => {
const n = new Set(prev); const n = new Set(prev);
@@ -1046,7 +1071,11 @@ function UpvotedDumpList(
<h2 className="profile-section-title">{title}</h2> <h2 className="profile-section-title">{title}</h2>
</div> </div>
{visibleDumps.length === 0 {visibleDumps.length === 0
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p> ? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: ( : (
<ul className="dump-feed"> <ul className="dump-feed">
{visibleDumps.map((dump) => { {visibleDumps.map((dump) => {
@@ -1073,7 +1102,9 @@ function UpvotedDumpList(
</ul> </ul>
)} )}
{visibleDumps.length > 0 && ( {visibleDumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all"><Trans>View all </Trans></Link> <Link to={viewAllHref} className="profile-view-all">
<Trans>View all </Trans>
</Link>
)} )}
</section> </section>
); );

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { SubmitEvent } from "react"; import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router"; import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
@@ -91,7 +91,9 @@ export function UserRegister() {
if (tokenState.status === "checking") { if (tokenState.status === "checking") {
return ( return (
<PageShell centered> <PageShell centered>
<p className="page-loading"><Trans>Checking invite</Trans></p> <p className="page-loading">
<Trans>Checking invite</Trans>
</p>
</PageShell> </PageShell>
); );
} }
@@ -112,7 +114,9 @@ export function UserRegister() {
return ( return (
<PageShell centered> <PageShell centered>
<div className="auth-card"> <div className="auth-card">
<h1 className="auth-card-title"><Trans>Register</Trans></h1> <h1 className="auth-card-title">
<Trans>Register</Trans>
</h1>
{formState.status === "error" && ( {formState.status === "error" && (
<ErrorCard title={t`Registration failed`} message={formState.error} /> <ErrorCard title={t`Registration failed`} message={formState.error} />
@@ -126,6 +130,7 @@ export function UserRegister() {
required required
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`} pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
title={t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`} title={t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
maxLength={VALIDATION.USERNAME_MAX}
disabled={formState.status === "submitting"} disabled={formState.status === "submitting"}
autoFocus autoFocus
/> />
@@ -157,7 +162,9 @@ export function UserRegister() {
</form> </form>
<p className="auth-card-footer"> <p className="auth-card-footer">
<Trans>Already have an account? <Link to="/login">Log in</Link></Trans> <Trans>
Already have an account? <Link to="/login">Log in</Link>
</Trans>
</p> </p>
</div> </div>
</PageShell> </PageShell>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
@@ -69,6 +69,7 @@ export function UserUpvoted() {
useEffect(() => { useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return; if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) { if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(myVotes)); setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes); prevMyVotesRef.current = new Set(myVotes);
@@ -87,6 +88,8 @@ export function UserUpvoted() {
if (voterId !== profileUserId) return; if (voterId !== profileUserId) return;
if (action === "remove") { if (action === "remove") {
// setVotedIds and startFading must fire together to avoid a render with
// stale votedIds between the two updates.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds((prev) => { setVotedIds((prev) => {
const n = new Set(prev); const n = new Set(prev);
@@ -116,7 +119,9 @@ export function UserUpvoted() {
if (state.status === "loading") { if (state.status === "loading") {
return ( return (
<PageShell> <PageShell>
<p className="page-loading"><Trans>Loading</Trans></p> <p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell> </PageShell>
); );
} }
@@ -148,7 +153,11 @@ export function UserUpvoted() {
/> />
{visibleDumps.length === 0 {visibleDumps.length === 0
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p> ? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: ( : (
<ul className="dump-feed"> <ul className="dump-feed">
{visibleDumps.map((dump) => { {visibleDumps.map((dump) => {
@@ -177,11 +186,21 @@ export function UserUpvoted() {
<div ref={sentinelRef} /> <div ref={sentinelRef} />
{loadingMore && ( {loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p> <p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)} )}
{!hasMore && visibleDumps.length > 0 && ( {!hasMore && visibleDumps.length > 0 && (
<p className="index-status"> <p className="index-status">
<Trans>All <Plural value={votes.length} one="# upvoted dump" other="# upvoted dumps" /> loaded.</Trans> <Trans>
All{" "}
<Plural
value={votes.length}
one="# upvoted dump"
other="# upvoted dumps"
/>{" "}
loaded.
</Trans>
</p> </p>
)} )}
</PageShell> </PageShell>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx"; import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -71,7 +71,11 @@ function FollowedSubFeed({
const sentinelRef = useInfiniteScroll(onLoadMore, enabled); const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
if (state.status === "loading") { if (state.status === "loading") {
return <p className="index-status"><Trans>Loading</Trans></p>; return (
<p className="index-status">
<Trans>Loading</Trans>
</p>
);
} }
if (state.status === "error") { if (state.status === "error") {
return <ErrorCard title={t`Failed to load`} message={state.error} />; return <ErrorCard title={t`Failed to load`} message={state.error} />;
@@ -100,7 +104,11 @@ function FollowedSubFeed({
))} ))}
</ul> </ul>
<div ref={sentinelRef} /> <div ref={sentinelRef} />
{state.loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>} {state.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
</> </>
); );
} }
@@ -219,8 +227,7 @@ export function FollowedFeed({
}) })
); );
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [token, usersState.status, playlistsState.status]);
}, [token]);
// Scroll save // Scroll save
useScrollSave( useScrollSave(

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx"; import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -26,10 +26,20 @@ export function HotFeed(
[dumps], [dumps],
); );
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>; if (loading) {
return (
<p className="index-status">
<Trans>Loading</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />; if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) { if (sorted.length === 0) {
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>; return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
} }
return ( return (
@@ -49,9 +59,15 @@ export function HotFeed(
))} ))}
</ul> </ul>
<div ref={sentinelRef} /> <div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>} {loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && sorted.length > 0 && ( {!hasMore && sorted.length > 0 && (
<p className="feed-end"><Trans>You've reached the end.</Trans></p> <p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)} )}
</> </>
); );

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ErrorCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx";
import { import {
@@ -38,10 +38,20 @@ export function JournalFeed(
}); });
}, [dumps]); }, [dumps]);
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>; if (loading) {
return (
<p className="index-status">
<Trans>Loading</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />; if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (tiered.length === 0) { if (tiered.length === 0) {
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>; return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
} }
return ( return (
@@ -62,9 +72,15 @@ export function JournalFeed(
))} ))}
</ul> </ul>
<div ref={sentinelRef} /> <div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>} {loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && tiered.length > 0 && ( {!hasMore && tiered.length > 0 && (
<p className="feed-end"><Trans>You've reached the end.</Trans></p> <p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)} )}
</> </>
); );

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx"; import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -26,10 +26,20 @@ export function NewFeed(
[dumps], [dumps],
); );
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>; if (loading) {
return (
<p className="index-status">
<Trans>Loading</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />; if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) { if (sorted.length === 0) {
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>; return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
} }
return ( return (
@@ -49,9 +59,15 @@ export function NewFeed(
))} ))}
</ul> </ul>
<div ref={sentinelRef} /> <div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>} {loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && sorted.length > 0 && ( {!hasMore && sorted.length > 0 && (
<p className="feed-end"><Trans>You've reached the end.</Trans></p> <p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)} )}
</> </>
); );

5
src/utils/charCount.ts Normal file
View File

@@ -0,0 +1,5 @@
export function charCountClass(len: number, max: number): string {
if (len >= max) return " text-editor-count--danger";
if (len >= max * 0.85) return " text-editor-count--warn";
return "";
}

View File

@@ -1,6 +1,54 @@
import { defineConfig } from "vite"; import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import { lingui } from "@lingui/vite-plugin"; import { lingui } from "@lingui/vite-plugin";
import fs from "node:fs";
import path from "node:path";
function manifestPlugin(): Plugin {
const cssPath = path.resolve("src/index.css");
const outPath = path.resolve("public/manifest.webmanifest");
function cssVar(rootBlock: string, name: string): string | undefined {
return rootBlock.match(
new RegExp(`${name.replace("-", "\\-")}:\\s*(#[0-9a-fA-F]{3,8})`),
)?.[1];
}
function generate() {
const css = fs.readFileSync(cssPath, "utf-8");
// Only read the first :root block — dark-mode defaults, before any @media overrides
const rootBlock = css.match(/:root\s*\{([^}]+)\}/)?.[1] ?? "";
const bgColor = cssVar(rootBlock, "--color-bg") ?? "#111827";
const manifest = {
name: "gerbeur",
short_name: "gerbeur",
start_url: "/",
display: "standalone",
background_color: bgColor,
theme_color: bgColor,
icons: [{ src: "/favicon.svg", type: "image/svg+xml", sizes: "any" }],
share_target: {
action: "/",
method: "GET",
params: { url: "share_url", title: "share_title", text: "share_text" },
},
};
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + "\n");
}
return {
name: "generate-manifest",
buildStart: generate,
configureServer(server) {
generate();
server.watcher.on("change", (file) => {
if (path.resolve(file) === cssPath) generate();
});
},
};
}
export default defineConfig({ export default defineConfig({
server: { server: {
@@ -10,6 +58,7 @@ export default defineConfig({
}, },
}, },
plugins: [ plugins: [
manifestPlugin(),
lingui(), lingui(),
react({ react({
plugins: [["@lingui/swc-plugin", {}]], plugins: [["@lingui/swc-plugin", {}]],