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 { 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 = {
name: "generic",
@@ -18,14 +26,39 @@ export const genericProvider: RichContentProvider = {
}
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 {
type: "generic",
url,
title: extractOgTag(html, "title"),
description: extractOgTag(html, "description"),
thumbnailUrl: extractOgTag(html, "image"),
siteName: extractOgTag(html, "site_name"),
title,
description,
thumbnailUrl,
siteName,
};
},
};

View File

@@ -83,6 +83,199 @@ export function extractOgTag(
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 {
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
if (hostname === "localhost" || hostname === "::1") return true;

View File

@@ -7,10 +7,14 @@
<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">
<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>
</head>
<body>
<div id="root"></div>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</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;
gap: 0.75rem;
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-radius: 0 0 12px 12px;
}
@@ -740,7 +744,11 @@
min-width: 0;
height: 48px;
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;
overflow: hidden;
cursor: pointer;
@@ -2354,7 +2362,6 @@ body.has-player .fab-new {
justify-content: center;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-bg);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
@@ -2370,6 +2377,7 @@ body.has-player .fab-new {
.playlist-card-icon {
font-size: 1.4rem;
opacity: 0.7;
line-height: 1;
}
/* Fill the 48×48 preview box and center content for media buttons */
@@ -2380,10 +2388,16 @@ body.has-player .fab-new {
justify-content: center;
}
.dump-card-preview .rich-content-compact {
width: 48px;
height: 48px;
justify-content: center;
}
.dump-card-preview .rich-content-compact-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 0;
border: none;
}
@@ -3450,10 +3464,12 @@ body.has-player .fab-new {
.journal-card--large .journal-card-comment {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--medium .journal-card-comment {
-webkit-line-clamp: 1;
line-clamp: 1;
}
.journal-card-footer {
@@ -3791,6 +3807,28 @@ body.has-player .fab-new {
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 {
position: absolute;
top: 100%;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
import { type RefObject, useCallback, useRef, useState } from "react";
// Trigger: ':' not preceded by a word character, followed by 1+ word chars
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z0-9_+-]{1,})$/;
// Trigger: ':' not preceded by a word character, followed by a letter then
// 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 {
open: boolean;

View File

@@ -5,7 +5,9 @@ type Locale = (typeof SUPPORTED)[number];
function detectLocale(): 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";
}

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

View File

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

View File

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

View File

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

View File

@@ -83,9 +83,21 @@ export function Index() {
);
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";
// 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 ──
useEffect(() => {
@@ -241,6 +253,7 @@ export function Index() {
</div>
}
disableNew={dumpsState.status === "error"}
initialDumpUrl={shareUrl || undefined}
/>
<div className="index-below-header">

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
@@ -203,11 +203,15 @@ export function Search() {
)}
{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" && (
<p className="search-page-empty"><Trans>Searching</Trans></p>
<p className="search-page-empty">
<Trans>Searching</Trans>
</p>
)}
{state.status === "error" && (
@@ -236,10 +240,14 @@ export function Search() {
)}
<div ref={sentinelRef} />
{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 && (
<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.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">
{state.playlists.map((p) => (

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans, Plural } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
@@ -47,7 +47,9 @@ export function UserDumps() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading</Trans></p>
<p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell>
);
}
@@ -89,7 +91,11 @@ export function UserDumps() {
)}
{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">
{dumps.map((dump) => (
@@ -108,10 +114,18 @@ export function UserDumps() {
)}
<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 && (
<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>
)}
</PageShell>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
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 { API_URL, VALIDATION } from "../config/api.ts";
@@ -91,7 +91,9 @@ export function UserRegister() {
if (tokenState.status === "checking") {
return (
<PageShell centered>
<p className="page-loading"><Trans>Checking invite</Trans></p>
<p className="page-loading">
<Trans>Checking invite</Trans>
</p>
</PageShell>
);
}
@@ -112,7 +114,9 @@ export function UserRegister() {
return (
<PageShell centered>
<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" && (
<ErrorCard title={t`Registration failed`} message={formState.error} />
@@ -126,6 +130,7 @@ export function UserRegister() {
required
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
title={t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
maxLength={VALIDATION.USERNAME_MAX}
disabled={formState.status === "submitting"}
autoFocus
/>
@@ -157,7 +162,9 @@ export function UserRegister() {
</form>
<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>
</div>
</PageShell>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 { API_URL } from "../config/api.ts";
@@ -69,6 +69,7 @@ export function UserUpvoted() {
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes);
@@ -87,6 +88,8 @@ export function UserUpvoted() {
if (voterId !== profileUserId) return;
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
setVotedIds((prev) => {
const n = new Set(prev);
@@ -116,7 +119,9 @@ export function UserUpvoted() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading</Trans></p>
<p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell>
);
}
@@ -148,7 +153,11 @@ export function UserUpvoted() {
/>
{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">
{visibleDumps.map((dump) => {
@@ -177,11 +186,21 @@ export function UserUpvoted() {
<div ref={sentinelRef} />
{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 && (
<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>
)}
</PageShell>

View File

@@ -1,5 +1,5 @@
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 { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -71,7 +71,11 @@ function FollowedSubFeed({
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
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") {
return <ErrorCard title={t`Failed to load`} message={state.error} />;
@@ -100,7 +104,11 @@ function FollowedSubFeed({
))}
</ul>
<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]);
}, [token, usersState.status, playlistsState.status]);
// Scroll save
useScrollSave(

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -26,10 +26,20 @@ export function HotFeed(
[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 (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 (
@@ -49,9 +59,15 @@ export function HotFeed(
))}
</ul>
<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 && (
<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 { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import {
@@ -38,10 +38,20 @@ export function JournalFeed(
});
}, [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 (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 (
@@ -62,9 +72,15 @@ export function JournalFeed(
))}
</ul>
<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 && (
<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 { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -26,10 +26,20 @@ export function NewFeed(
[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 (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 (
@@ -49,9 +59,15 @@ export function NewFeed(
))}
</ul>
<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 && (
<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 { 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({
server: {
@@ -10,6 +58,7 @@ export default defineConfig({
},
},
plugins: [
manifestPlugin(),
lingui(),
react({
plugins: [["@lingui/swc-plugin", {}]],