v3: localization fixes, char counters & limits on all text fields, ux fixes
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
24
public/manifest.webmanifest
Normal file
24
public/manifest.webmanifest
Normal 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
2
public/sw.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
self.addEventListener('install', () => self.skipWaiting());
|
||||||
|
self.addEventListener('activate', () => self.clients.claim());
|
||||||
50
src/App.css
50
src/App.css
@@ -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%;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
22
src/components/CountedInput.tsx
Normal file
22
src/components/CountedInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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?"
|
||||||
|
|||||||
@@ -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 ?"
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
5
src/utils/charCount.ts
Normal 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 "";
|
||||||
|
}
|
||||||
@@ -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", {}]],
|
||||||
|
|||||||
Reference in New Issue
Block a user