From a69788c15b84429c6f338929435bf9c4eb02daf3 Mon Sep 17 00:00:00 2001 From: khannurien Date: Fri, 3 Apr 2026 19:47:37 +0000 Subject: [PATCH] v3: localization fixes, char counters & limits on all text fields, ux fixes --- api/services/providers/generic.ts | 43 ++++- api/services/rich-content-service.ts | 193 +++++++++++++++++++++ index.html | 4 + public/manifest.webmanifest | 24 +++ public/sw.js | 2 + src/App.css | 50 +++++- src/components/AppHeader.tsx | 19 +- src/components/CommentThread.tsx | 53 ++++-- src/components/ConfirmModal.tsx | 2 +- src/components/CountedInput.tsx | 22 +++ src/components/DumpCreateModal.tsx | 46 +++-- src/components/FileDropZone.tsx | 12 +- src/components/FilePreview.tsx | 54 ++++-- src/components/FollowButton.tsx | 2 +- src/components/GlobalPlayer.tsx | 15 +- src/components/JournalCard.tsx | 1 + src/components/MediaPlayer.tsx | 134 +++++++++----- src/components/NewPlaylistForm.tsx | 8 +- src/components/PlaylistCard.tsx | 6 +- src/components/PlaylistCreateForm.tsx | 20 ++- src/components/PlaylistMembershipPanel.tsx | 12 +- src/components/RichContentCard.tsx | 43 ++++- src/components/TextEditor.tsx | 25 ++- src/components/UserMenu.tsx | 2 +- src/hooks/useEmojiTrigger.ts | 6 +- src/i18n.ts | 4 +- src/locales/en.js | 6 +- src/locales/en.mjs | 4 +- src/locales/en.po | 79 +++++---- src/locales/fr.po | 84 +++++---- src/pages/Dump.tsx | 10 +- src/pages/DumpEdit.tsx | 31 +++- src/pages/Index.tsx | 15 +- src/pages/Notifications.tsx | 20 ++- src/pages/PlaylistDetail.tsx | 41 +++-- src/pages/Search.tsx | 24 ++- src/pages/UserDumps.tsx | 26 ++- src/pages/UserLogin.tsx | 6 +- src/pages/UserPlaylists.tsx | 26 ++- src/pages/UserPublicProfile.tsx | 69 ++++++-- src/pages/UserRegister.tsx | 15 +- src/pages/UserUpvoted.tsx | 29 +++- src/pages/index/FollowedFeed.tsx | 17 +- src/pages/index/HotFeed.tsx | 26 ++- src/pages/index/JournalFeed.tsx | 26 ++- src/pages/index/NewFeed.tsx | 26 ++- src/utils/charCount.ts | 5 + vite.config.ts | 51 +++++- 48 files changed, 1133 insertions(+), 305 deletions(-) create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 src/components/CountedInput.tsx create mode 100644 src/utils/charCount.ts diff --git a/api/services/providers/generic.ts b/api/services/providers/generic.ts index 7278822..3bced69 100644 --- a/api/services/providers/generic.ts +++ b/api/services/providers/generic.ts @@ -1,6 +1,14 @@ import type { RichContent } from "../../model/interfaces.ts"; import type { RichContentProvider } from "../rich-content-service.ts"; -import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; +import { + extractBestIcon, + extractJsonLd, + extractLargeImage, + extractMetaName, + extractOgTag, + extractPageTitle, + fetchWithTimeout, +} from "../rich-content-service.ts"; export const genericProvider: RichContentProvider = { name: "generic", @@ -18,14 +26,39 @@ export const genericProvider: RichContentProvider = { } const html = await res.text(); + const ld = extractJsonLd(html); + + // Title: og:title → twitter:title → JSON-LD → + const title = extractOgTag(html, "title") ?? + extractMetaName(html, "twitter:title") ?? + ld.title ?? + extractPageTitle(html); + + // Description: og:description → twitter:description → JSON-LD → <meta name="description"> + const description = extractOgTag(html, "description") ?? + extractMetaName(html, "twitter:description") ?? + ld.description ?? + extractMetaName(html, "description"); + + // Image: og:image → twitter:image → JSON-LD → first large <img> → best icon → /favicon.ico + const thumbnailUrl = extractOgTag(html, "image") ?? + extractMetaName(html, "twitter:image") ?? + ld.thumbnailUrl ?? + extractLargeImage(html, url) ?? + extractBestIcon(html, url) ?? + `${new URL(url).origin}/favicon.ico`; + + // Site name: og:site_name → hostname + const siteName = extractOgTag(html, "site_name") ?? + new URL(url).hostname.replace(/^www\./, ""); return { type: "generic", url, - title: extractOgTag(html, "title"), - description: extractOgTag(html, "description"), - thumbnailUrl: extractOgTag(html, "image"), - siteName: extractOgTag(html, "site_name"), + title, + description, + thumbnailUrl, + siteName, }; }, }; diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index 6682332..85f882b 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -83,6 +83,199 @@ export function extractOgTag( return undefined; } +/** Extract content from `<meta name="…" content="…">` (both attribute orderings). */ +export function extractMetaName( + html: string, + name: string, +): string | undefined { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const patterns = [ + new RegExp( + `<meta[^>]+name=["']${escaped}["'][^>]+content=["']([^"']+)["']`, + "i", + ), + new RegExp( + `<meta[^>]+content=["']([^"']+)["'][^>]+name=["']${escaped}["']`, + "i", + ), + ]; + for (const pattern of patterns) { + const match = html.match(pattern); + if (match) return decodeHtmlEntities(match[1]); + } + return undefined; +} + +/** Extract the text content of the `<title>` element. */ +export function extractPageTitle(html: string): string | undefined { + const match = html.match(/<title[^>]*>([^<]+)<\/title>/i); + return match ? decodeHtmlEntities(match[1].trim()) : undefined; +} + +// ── JSON-LD helpers (file-private) ──────────────────────────────────────────── + +type JsonLdResult = { + title?: string; + description?: string; + thumbnailUrl?: string; +}; + +function ldString(v: unknown): string | undefined { + if (typeof v === "string" && v.trim()) return v.trim(); + if (Array.isArray(v) && typeof v[0] === "string" && v[0].trim()) { + return v[0].trim(); + } + return undefined; +} + +function ldImage(v: unknown): string | undefined { + if ( + typeof v === "string" && + (v.startsWith("http://") || v.startsWith("https://")) + ) return v; + if (Array.isArray(v)) return ldImage(v[0]); + if (v && typeof v === "object") { + const o = v as Record<string, unknown>; + return ldImage(o.url ?? o.contentUrl); + } + return undefined; +} + +function ldExtractNode(data: unknown): JsonLdResult { + if (Array.isArray(data)) { + for (const item of data) { + const r = ldExtractNode(item); + if (r.title || r.thumbnailUrl) return r; + } + return {}; + } + if (!data || typeof data !== "object") return {}; + const o = data as Record<string, unknown>; + if (o["@graph"]) return ldExtractNode(o["@graph"]); + return { + title: ldString(o.name ?? o.headline), + description: ldString(o.description), + thumbnailUrl: ldImage(o.image ?? o.thumbnailUrl ?? o.thumbnail), + }; +} + +/** + * Parse every `<script type="application/ld+json">` block and return the first + * node that yields a title or image. Handles `@graph`, arrays, and the common + * `image` shapes (string, string[], ImageObject). + */ +export function extractJsonLd(html: string): JsonLdResult { + const pattern = + /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + let match: RegExpExecArray | null; + while ((match = pattern.exec(html)) !== null) { + try { + const result = ldExtractNode(JSON.parse(match[1])); + if (result.title || result.thumbnailUrl) return result; + } catch { /* invalid JSON — skip */ } + } + return {}; +} + +/** + * Return the `src` of the first `<img>` whose declared width or height is at + * least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs. + */ +export function extractLargeImage( + html: string, + baseUrl: string, + minSize = 200, +): string | undefined { + const imgPattern = /<img[^>]+>/gi; + let match: RegExpExecArray | null; + while ((match = imgPattern.exec(html)) !== null) { + const tag = match[0]; + const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1]; + if (!src || src.startsWith("data:")) continue; + const w = parseInt(/\bwidth=["']?(\d+)/i.exec(tag)?.[1] ?? "0"); + const h = parseInt(/\bheight=["']?(\d+)/i.exec(tag)?.[1] ?? "0"); + if (w >= minSize && h >= minSize) { + try { + return new URL(src, baseUrl).toString(); + } catch { + continue; + } + } + } + return undefined; +} + +/** + * Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank + * them by declared size (largest wins), and return the best resolved URL. + * Falls back to the first match when no `sizes` attribute is present. + */ +export function extractBestIcon( + html: string, + baseUrl: string, +): string | undefined { + const linkRe = /<link[^>]+>/gi; + const relRe = /\brel=["']([^"']+)["']/i; + const hrefRe = /\bhref=["']([^"']+)["']/i; + const sizesRe = /\bsizes=["']([^"']+)["']/i; + + const candidates: { href: string; area: number }[] = []; + + let m: RegExpExecArray | null; + while ((m = linkRe.exec(html)) !== null) { + const tag = m[0]; + const rel = relRe.exec(tag)?.[1] ?? ""; + if (!/\bicon\b/i.test(rel) && !/apple-touch-icon/i.test(rel)) continue; + const href = hrefRe.exec(tag)?.[1]; + if (!href) continue; + const sizesStr = sizesRe.exec(tag)?.[1] ?? ""; + const sm = sizesStr.match(/(\d+)x(\d+)/i); + const area = sm ? parseInt(sm[1]) * parseInt(sm[2]) : 0; + try { + candidates.push({ href: new URL(href, baseUrl).toString(), area }); + } catch { + continue; + } + } + + if (candidates.length === 0) return undefined; + candidates.sort((a, b) => b.area - a.area); + return candidates[0].href; +} + +/** + * Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`, + * resolved to an absolute URL using `baseUrl`. + */ +export function extractLinkHref( + html: string, + relFragment: string, + baseUrl: string, +): string | undefined { + const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const patterns = [ + new RegExp( + `<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`, + "i", + ), + new RegExp( + `<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`, + "i", + ), + ]; + for (const pattern of patterns) { + const match = html.match(pattern); + if (match) { + try { + return new URL(match[1], baseUrl).toString(); + } catch { + return undefined; + } + } + } + return undefined; +} + function isPrivateHost(hostname: string): boolean { // Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated. if (hostname === "localhost" || hostname === "::1") return true; diff --git a/index.html b/index.html index c79533d..3562585 100644 --- a/index.html +++ b/index.html @@ -7,10 +7,14 @@ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Saira:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet"> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="theme-color" content="#111827" /> + <link rel="manifest" href="/manifest.webmanifest" /> + <link rel="apple-touch-icon" href="/favicon.svg" /> <title>Dumps
+ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..d1818e3 --- /dev/null +++ b/public/manifest.webmanifest @@ -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" + } + } +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..7ba618e --- /dev/null +++ b/public/sw.js @@ -0,0 +1,2 @@ +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', () => self.clients.claim()); diff --git a/src/App.css b/src/App.css index 378d22f..69bde4c 100644 --- a/src/App.css +++ b/src/App.css @@ -539,7 +539,11 @@ align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; - background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface) 92%); + background: color-mix( + in srgb, + var(--color-accent) 8%, + var(--color-surface) 92% + ); border: 2px solid var(--color-border); border-radius: 0 0 12px 12px; } @@ -740,7 +744,11 @@ min-width: 0; height: 48px; border-radius: 3px; - background: color-mix(in srgb, var(--color-accent) 12%, var(--color-border) 88%); + background: color-mix( + in srgb, + var(--color-accent) 12%, + var(--color-border) 88% + ); position: relative; overflow: hidden; cursor: pointer; @@ -2354,7 +2362,6 @@ body.has-player .fab-new { justify-content: center; border-radius: 6px; overflow: hidden; - border: 1px solid var(--color-border); background: var(--color-bg); transition: transform 0.18s ease, box-shadow 0.18s ease; } @@ -2370,6 +2377,7 @@ body.has-player .fab-new { .playlist-card-icon { font-size: 1.4rem; opacity: 0.7; + line-height: 1; } /* Fill the 48×48 preview box and center content for media buttons */ @@ -2380,10 +2388,16 @@ body.has-player .fab-new { justify-content: center; } +.dump-card-preview .rich-content-compact { + width: 48px; + height: 48px; + justify-content: center; +} + .dump-card-preview .rich-content-compact-thumbnail { - width: 100%; - height: 100%; - object-fit: cover; + width: 48px; + height: 48px; + object-fit: contain; border-radius: 0; border: none; } @@ -3450,10 +3464,12 @@ body.has-player .fab-new { .journal-card--large .journal-card-comment { -webkit-line-clamp: 3; + line-clamp: 3; } .journal-card--medium .journal-card-comment { -webkit-line-clamp: 1; + line-clamp: 1; } .journal-card-footer { @@ -3791,6 +3807,28 @@ body.has-player .fab-new { outline-offset: -2px; } +.input-with-count { + display: flex; + flex-direction: column; + width: 100%; +} + +.text-editor-count { + display: block; + text-align: right; + font-size: 0.7rem; + color: var(--color-text-muted); + margin-top: 2px; + user-select: none; +} +.text-editor-count--warn { + color: var(--color-warning, #c97a00); +} +.text-editor-count--danger { + color: var(--color-danger, #c0392b); + font-weight: 600; +} + .mention-dropdown { position: absolute; top: 100%; diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index cb1ef9c..76d3c94 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useState } from "react"; import { Link, useNavigate } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; @@ -9,12 +9,16 @@ import { NotificationBell } from "./NotificationBell.tsx"; import { UserMenu } from "./UserMenu.tsx"; export function AppHeader( - { centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean }, + { centerSlot, disableNew, initialDumpUrl }: { + centerSlot?: ReactNode; + disableNew?: boolean; + initialDumpUrl?: string; + }, ) { const { user } = useAuth(); const { wsStatus, wsErrorMessage } = useWS(); const navigate = useNavigate(); - const [createModalOpen, setCreateModalOpen] = useState(false); + const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl); return ( <> @@ -76,13 +80,18 @@ export function AppHeader( {wsStatus === "disconnected" && wsErrorMessage && (
- Live updates unavailable.{" "} + + Live updates unavailable. + {" "} {wsErrorMessage}
)} {createModalOpen && ( - setCreateModalOpen(false)} /> + setCreateModalOpen(false)} + initialUrl={initialDumpUrl} + /> )} ); diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index 9b8c7c3..be2a8a7 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -1,8 +1,8 @@ import React, { useMemo, useRef, useState } from "react"; import { Link } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Plural, Trans } from "@lingui/react/macro"; -import { API_URL } from "../config/api.ts"; +import { API_URL, VALIDATION } from "../config/api.ts"; import type { Comment, CreateCommentRequest, @@ -79,7 +79,10 @@ function CommentNode({ async function handleReply(e?: React.SubmitEvent) { e?.preventDefault(); - if (!replyBody.trim() || !token) return; + if ( + !replyBody.trim() || !token || + replyBody.length > VALIDATION.COMMENT_BODY_MAX + ) return; setSubmitting(true); setReplyError(null); try { @@ -124,7 +127,10 @@ function CommentNode({ async function handleEditSave(e?: React.SubmitEvent) { e?.preventDefault(); - if (!editBody.trim() || !token) return; + if ( + !editBody.trim() || !token || + editBody.length > VALIDATION.COMMENT_BODY_MAX + ) return; setEditSubmitting(true); setEditError(null); try { @@ -244,17 +250,24 @@ function CommentNode({ onSubmit={handleEditSave} autoResize rows={1} + maxLength={VALIDATION.COMMENT_BODY_MAX} /> {editError && ( - + )}
{topLevelBody.trim() && (
@@ -411,7 +424,8 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) { ); } @@ -202,7 +226,13 @@ export default function FilePreview( {open && ( - setOpen(false)}> + setOpen(false)}> { onCreated(playlist); diff --git a/src/components/PlaylistCard.tsx b/src/components/PlaylistCard.tsx index b318cf7..eba6ad2 100644 --- a/src/components/PlaylistCard.tsx +++ b/src/components/PlaylistCard.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Plural, Trans } from "@lingui/react/macro"; import { API_URL } from "../config/api.ts"; import type { Playlist } from "../model.ts"; @@ -68,7 +68,9 @@ export function PlaylistCard( playlist.isPublic ? "" : " playlist-badge--private" }`} > - {playlist.isPublic ? public : private} + {playlist.isPublic + ? public + : private} {playlist.ownerUsername && !isOwner && ( { e.preventDefault(); - if (!title.trim()) return; + if ( + !title.trim() || description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX + ) return; setSubmitting(true); setError(null); try { @@ -64,19 +67,21 @@ export function PlaylistCreateForm( return (
- setTitle(e.target.value)} autoFocus required + maxLength={VALIDATION.PLAYLIST_TITLE_MAX} />
- {error && } + {error && ( + + )}
+ ); + } + return ( void; onKeyDown?: (e: React.KeyboardEvent) => void; + maxLength?: number; } export const TextEditor = forwardRef( @@ -44,9 +47,11 @@ export const TextEditor = forwardRef( autoResize = false, onSubmit, onKeyDown, + maxLength, }, ref, ) { + const { i18n } = useLingui(); const textareaRef = useRef(null); const emojiViewportRef = useRef(null); const emojiSearchRef = useRef(null); @@ -205,6 +210,15 @@ export const TextEditor = forwardRef( id={id} className={className} /> + {maxLength != null && ( + + {value.length} / {maxLength} + + )} {mentionOpen && ( ( > handleEmojiSelect(e.emoji)} + locale={i18n.locale as EmojiLocale} >
( // frimousse's onFocusCapture can detect it and arm arrow-key nav tabIndex={-1} > - Loading… - No emoji found. + + Loading… + + + No emoji found. + diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index ea94682..d6d36a8 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Avatar } from "./Avatar.tsx"; import type { User } from "../model.ts"; diff --git a/src/hooks/useEmojiTrigger.ts b/src/hooks/useEmojiTrigger.ts index 33f15a0..1fd1434 100644 --- a/src/hooks/useEmojiTrigger.ts +++ b/src/hooks/useEmojiTrigger.ts @@ -1,7 +1,9 @@ import { type RefObject, useCallback, useRef, useState } from "react"; -// Trigger: ':' not preceded by a word character, followed by 1+ word chars -const TRIGGER_RE = /(?\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6Qly-0\":[\"a comment\"],\"6gRgw8\":[\"Retry\"],\"7JBW66\":[\"Forbidden\"],\"7PHCIN\":[\"Cancel removal\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8ZsakT\":[\"Password\"],\"8pxhI8\":[\"Please select a file.\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"CI50ct\":[\"Upvoted\"],\"Cj24wt\":[\"Registering…\"],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"ECiS12\":[\"View dump →\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GsRMX3\":[\"Playlist not found\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"ImOQa9\":[\"Reply\"],\"J2eKUI\":[\"File\"],\"Jd58Fo\":[\"Hot\"],\"K1JdNl\":[\"Username already exists\"],\"KDGWg5\":[\"Remove from playlist\"],\"K_F6pa\":[\"Saving…\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"LPAv9E\":[[\"days\"],\"d ago\"],\"Lld1jm\":[\"Tell people about yourself…\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"Mq2B8E\":[[\"mins\"],\"m ago\"],\"NR0xa9\":[\"Tell the community what makes this worth their time...\"],\"Nn4kr3\":[\"+ New dump\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"PiH3UR\":[\"Copied!\"],\"Pwqkdw\":[\"Loading…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"SBTElJ\":[\"Searching…\"],\"Sxm8rQ\":[\"Users\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TM1ZbA\":[\"Playlists (\",[\"0\"],[\"1\"],\")\"],\"Tv9vbB\":[\"Follow playlist\"],\"U7u3q-\":[\"+ New\"],\"UOZith\":[\"Failed to post\"],\"UTiUFs\":[\"Fetching…\"],\"VnNJbN\":[\"From playlists\"],\"VyTYmS\":[\"Change avatar\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"WtkMN8\":[\"All \",[\"0\",\"plural\",{\"one\":[\"#\",\" upvoted dump\"],\"other\":[\"#\",\" upvoted dumps\"]}],\" loaded.\"],\"XILg0L\":[\"Invalid email address\"],\"XJy2oN\":[\"Logging in…\"],\"Xan6QP\":[\"New dump\"],\"Xi0Mn4\":[\"← Back to profile\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"YK1Dhc\":[\"a post\"],\"YaSA2K\":[\"Comment not found\"],\"YpkCca\":[\"No followed playlists yet.\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZmD2o6\":[\"Create & Add\"],\"_84wxb\":[\"All \",[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}],\" loaded.\"],\"_DwR-n\":[\"Creating…\"],\"_aept4\":[\"Post reply\"],\"_t4W-i\":[\"From people\"],\"aDvLhk\":[\"Add a comment…\"],\"alBtu4\":[\"Invalid or expired invite\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"cILfnJ\":[\"Remove file\"],\"cYP9Sb\":[\"+ Playlist\"],\"cnGeoo\":[\"Delete\"],\"d8DZWS\":[\"Open search\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dSKHAa\":[\"Invalid username or password\"],\"dTU6Wi\":[\"Password must be at most 128 characters\"],\"dbc28f\":[\"Why are you dumping this?\"],\"eFSqvc\":[\"Failed to post reply\"],\"ePK91l\":[\"Edit\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"en9o7K\":[\"Failed to post comment\"],\"fGxPOv\":[\"Add a bio…\"],\"fI-mNw\":[\"Playlists\"],\"f_akpP\":[\"Max 50 MB\"],\"fgLNSM\":[\"Register\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"iDNBZe\":[\"Notifications\"],\"ijVyoK\":[\"Could not reach the server. Please try again.\"],\"isRobC\":[\"New\"],\"jGrTH0\":[\"Not authenticated\"],\"jbernk\":[\"Loading profile…\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"kLttbL\":[\"Registration failed\"],\"kYYCil\":[\"File too large (max 50 MB)\"],\"l3JaOO\":[\"Cannot edit a deleted comment\"],\"lUDifl\":[\"Created (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nwtY4N\":[\"Something went wrong\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"qIMfNQ\":[\"Delete playlist\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qvFa8r\":[\"public\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rg9pXu\":[\"Search failed\"],\"rtpJqV\":[\"Dumps (\",[\"0\"],[\"1\"],\")\"],\"sBZMWb\":[\"Invalid URL\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"shHs8T\":[\"Enter a query to search.\"],\"siMTjB\":[\"File content is not a recognised image (JPEG, PNG, GIF, WebP)\"],\"smeBfS\":[\"Invalid invite\"],\"tfDRzk\":[\"Save\"],\"tqKwXl\":[\"Username must be 1–32 characters and contain only letters, numbers, or underscores\"],\"u1lDX2\":[\"Fetching preview…\"],\"u4pkXs\":[\"Invite already used\"],\"uD0qXQ\":[\"Drop a file here\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vuosjb\":[\"User menu\"],\"vwGkYB\":[\"Password must be at least 8 characters\"],\"wXO4Tg\":[[\"hrs\"],\"h ago\"],\"wbXKOv\":[\"File too large (max 50 MB).\"],\"wdiqRH\":[\"Admin access required\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"x4aBfU\":[\"Dump not found\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xOTzt5\":[\"just now\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y6sq5j\":[\"Following\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yz7wBu\":[\"Close\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}"); \ No newline at end of file +/*eslint-disable*/ module.exports = { + messages: JSON.parse( + '{"-K9EZb":["Add email…"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"0kWhlg":["File too large (max 5 MB)"],"1HfJWf":["Search dumps, users, playlists…"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2Hlmdt":["Write a reply…"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3T1cI4":["Unexpected server error"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6gRgw8":["Retry"],"7JBW66":["Forbidden"],"7PHCIN":["Cancel removal"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8ZsakT":["Password"],"8pxhI8":["Please select a file."],"9l4qcT":["Drop a replacement here"],"9uI_rE":["Undo"],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AQbgNR":["Follow ",["targetUsername"]],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade-6d":["Live updates unavailable."],"CI50ct":["Upvoted"],"Cj24wt":["Registering…"],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"]," upvoted <1>",["1"],""],"ECiS12":["View dump →"],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GNSsCc":["Failed to create playlist"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GsRMX3":["Playlist not found"],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"ImOQa9":["Reply"],"J2eKUI":["File"],"Jd58Fo":["Hot"],"K1JdNl":["Username already exists"],"KDGWg5":["Remove from playlist"],"K_F6pa":["Saving…"],"LLyMkV":["Followed (",["0"],["1"],")"],"LPAv9E":[["days"],"d ago"],"Lld1jm":["Tell people about yourself…"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"Mq2B8E":[["mins"],"m ago"],"NR0xa9":["Tell the community what makes this worth their time..."],"Nn4kr3":["+ New dump"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"PiH3UR":["Copied!"],"Pwqkdw":["Loading…"],"Q6n4F4":["Refresh metadata"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"SBTElJ":["Searching…"],"Sxm8rQ":["Users"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TM1ZbA":["Playlists (",["0"],["1"],")"],"Tv9vbB":["Follow playlist"],"U7u3q-":["+ New"],"UOZith":["Failed to post"],"UTiUFs":["Fetching…"],"VnNJbN":["From playlists"],"VyTYmS":["Change avatar"],"WpXcBJ":["Nothing here yet."],"WtkMN8":["All ",["0","plural",{"one":["#"," upvoted dump"],"other":["#"," upvoted dumps"]}]," loaded."],"XILg0L":["Invalid email address"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"YK1Dhc":["a post"],"YaSA2K":["Comment not found"],"YpkCca":["No followed playlists yet."],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZmD2o6":["Create & Add"],"_84wxb":["All ",["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]," loaded."],"_DwR-n":["Creating…"],"_aept4":["Post reply"],"_t4W-i":["From people"],"aDvLhk":["Add a comment…"],"alBtu4":["Invalid or expired invite"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"cILfnJ":["Remove file"],"cYP9Sb":["+ Playlist"],"cnGeoo":["Delete"],"d8DZWS":["Open search"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dSKHAa":["Invalid username or password"],"dTU6Wi":["Password must be at most 128 characters"],"dbc28f":["Why are you dumping this?"],"eFSqvc":["Failed to post reply"],"ePK91l":["Edit"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"en9o7K":["Failed to post comment"],"fGxPOv":["Add a bio…"],"fI-mNw":["Playlists"],"f_akpP":["Max 50 MB"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hYgDIe":["Create"],"he3ygx":["Copy"],"iDNBZe":["Notifications"],"ijVyoK":["Could not reach the server. Please try again."],"isRobC":["New"],"jGrTH0":["Not authenticated"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"kLttbL":["Registration failed"],"kYYCil":["File too large (max 50 MB)"],"l3JaOO":["Cannot edit a deleted comment"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qvFa8r":["public"],"rCbqPX":["This invite link is missing, expired, or already used."],"rg9pXu":["Search failed"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sBZMWb":["Invalid URL"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"shHs8T":["Enter a query to search."],"siMTjB":["File content is not a recognised image (JPEG, PNG, GIF, WebP)"],"smeBfS":["Invalid invite"],"tfDRzk":["Save"],"tqKwXl":["Username must be 1–32 characters and contain only letters, numbers, or underscores"],"u1lDX2":["Fetching preview…"],"u4pkXs":["Invite already used"],"uD0qXQ":["Drop a file here"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vuosjb":["User menu"],"vwGkYB":["Password must be at least 8 characters"],"wXO4Tg":[["hrs"],"h ago"],"wbXKOv":["File too large (max 50 MB)."],"wdiqRH":["Admin access required"],"wixIgH":["Already have an account? <0>Log in"],"x4aBfU":["Dump not found"],"xEWkgZ":["← Back to all dumps"],"xOTzt5":["just now"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', + ), +}; diff --git a/src/locales/en.mjs b/src/locales/en.mjs index 86d7f11..3d41f59 100644 --- a/src/locales/en.mjs +++ b/src/locales/en.mjs @@ -1 +1,3 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+K9EZb\":[\"Add email…\"],\"+Ya+b9\":[\"Failed to save\"],\"+siMqD\":[\"Journal\"],\"/84wxb\":[\"All \",[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}],\" loaded.\"],\"/DwR+n\":[\"Creating…\"],\"/aept4\":[\"Post reply\"],\"/t4W+i\":[\"From people\"],\"0kWhlg\":[\"File too large (max 5 MB)\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1cbYY/\":[\"Upvoted (\",[\"0\"],[\"1\"],\")\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2Hlmdt\":[\"Write a reply…\"],\"2ygf/L\":[\"← Back\"],\"3KKSM4\":[\"private\"],\"3T1cI4\":[\"Unexpected server error\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w/o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f/\":[\"Earlier\"],\"6Qly+0\":[\"a comment\"],\"6gRgw8\":[\"Retry\"],\"7JBW66\":[\"Forbidden\"],\"7PHCIN\":[\"Cancel removal\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8ZsakT\":[\"Password\"],\"8pxhI8\":[\"Please select a file.\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI/rE\":[\"Undo\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade+6d\":[\"Live updates unavailable.\"],\"CI50ct\":[\"Upvoted\"],\"Cj24wt\":[\"Registering…\"],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"ECiS12\":[\"View dump →\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GsRMX3\":[\"Playlist not found\"],\"H8pzW+\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"ImOQa9\":[\"Reply\"],\"J2eKUI\":[\"File\"],\"Jd58Fo\":[\"Hot\"],\"K/F6pa\":[\"Saving…\"],\"K1JdNl\":[\"Username already exists\"],\"KDGWg5\":[\"Remove from playlist\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"LPAv9E\":[[\"days\"],\"d ago\"],\"Lld1jm\":[\"Tell people about yourself…\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"Mq2B8E\":[[\"mins\"],\"m ago\"],\"NR0xa9\":[\"Tell the community what makes this worth their time...\"],\"Nn4kr3\":[\"+ New dump\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"PiH3UR\":[\"Copied!\"],\"Pwqkdw\":[\"Loading…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"SBTElJ\":[\"Searching…\"],\"Sxm8rQ\":[\"Users\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TM1ZbA\":[\"Playlists (\",[\"0\"],[\"1\"],\")\"],\"Tv9vbB\":[\"Follow playlist\"],\"U7u3q+\":[\"+ New\"],\"UOZith\":[\"Failed to post\"],\"UTiUFs\":[\"Fetching…\"],\"VnNJbN\":[\"From playlists\"],\"VyTYmS\":[\"Change avatar\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"WtkMN8\":[\"All \",[\"0\",\"plural\",{\"one\":[\"#\",\" upvoted dump\"],\"other\":[\"#\",\" upvoted dumps\"]}],\" loaded.\"],\"XILg0L\":[\"Invalid email address\"],\"XJy2oN\":[\"Logging in…\"],\"Xan6QP\":[\"New dump\"],\"Xi0Mn4\":[\"← Back to profile\"],\"XnL+Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"YK1Dhc\":[\"a post\"],\"YaSA2K\":[\"Comment not found\"],\"YpkCca\":[\"No followed playlists yet.\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZmD2o6\":[\"Create & Add\"],\"aDvLhk\":[\"Add a comment…\"],\"alBtu4\":[\"Invalid or expired invite\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn+\":[\"Loading playlist…\"],\"cILfnJ\":[\"Remove file\"],\"cYP9Sb\":[\"+ Playlist\"],\"cnGeoo\":[\"Delete\"],\"d8DZWS\":[\"Open search\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dSKHAa\":[\"Invalid username or password\"],\"dTU6Wi\":[\"Password must be at most 128 characters\"],\"dbc28f\":[\"Why are you dumping this?\"],\"eFSqvc\":[\"Failed to post reply\"],\"ePK91l\":[\"Edit\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"en9o7K\":[\"Failed to post comment\"],\"f/akpP\":[\"Max 50 MB\"],\"fGxPOv\":[\"Add a bio…\"],\"fI+mNw\":[\"Playlists\"],\"fgLNSM\":[\"Register\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ+sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"iDNBZe\":[\"Notifications\"],\"ijVyoK\":[\"Could not reach the server. Please try again.\"],\"isRobC\":[\"New\"],\"jGrTH0\":[\"Not authenticated\"],\"jbernk\":[\"Loading profile…\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"kLttbL\":[\"Registration failed\"],\"kYYCil\":[\"File too large (max 50 MB)\"],\"l3JaOO\":[\"Cannot edit a deleted comment\"],\"lUDifl\":[\"Created (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr/\":[\"Delete this comment?\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nwtY4N\":[\"Something went wrong\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"qIMfNQ\":[\"Delete playlist\"],\"qbDAcy\":[\"Dump it\"],\"qgx/78\":[\"Follow some public playlists to see their dumps here.\"],\"qvFa8r\":[\"public\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rg9pXu\":[\"Search failed\"],\"rtpJqV\":[\"Dumps (\",[\"0\"],[\"1\"],\")\"],\"sBZMWb\":[\"Invalid URL\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"shHs8T\":[\"Enter a query to search.\"],\"siMTjB\":[\"File content is not a recognised image (JPEG, PNG, GIF, WebP)\"],\"smeBfS\":[\"Invalid invite\"],\"tfDRzk\":[\"Save\"],\"tqKwXl\":[\"Username must be 1–32 characters and contain only letters, numbers, or underscores\"],\"u1lDX2\":[\"Fetching preview…\"],\"u4pkXs\":[\"Invite already used\"],\"uD0qXQ\":[\"Drop a file here\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vuosjb\":[\"User menu\"],\"vwGkYB\":[\"Password must be at least 8 characters\"],\"wXO4Tg\":[[\"hrs\"],\"h ago\"],\"wbXKOv\":[\"File too large (max 50 MB).\"],\"wdiqRH\":[\"Admin access required\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"x4aBfU\":[\"Dump not found\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xOTzt5\":[\"just now\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O/u\":[\"Delete dump\"],\"y/0uwd\":[\"Yesterday\"],\"y6sq5j\":[\"Following\"],\"yA/6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yQ2kGp\":[\"Load more\"],\"yz7wBu\":[\"Close\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}"); \ No newline at end of file +/*eslint-disable*/ export const messages = JSON.parse( + '{"+K9EZb":["Add email…"],"+Ya+b9":["Failed to save"],"+siMqD":["Journal"],"/84wxb":["All ",["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]," loaded."],"/DwR+n":["Creating…"],"/aept4":["Post reply"],"/t4W+i":["From people"],"0kWhlg":["File too large (max 5 MB)"],"1HfJWf":["Search dumps, users, playlists…"],"1cbYY/":["Upvoted (",["0"],["1"],")"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2Hlmdt":["Write a reply…"],"2ygf/L":["← Back"],"3KKSM4":["private"],"3T1cI4":["Unexpected server error"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"49voTZ":[["label"]," (",["count"],")"],"4B6w/o":["Dumped!"],"4GKuCs":["Login failed"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5cC8f2":["Edited ",["0"]],"5oD9f/":["Earlier"],"6Qly+0":["a comment"],"6gRgw8":["Retry"],"7JBW66":["Forbidden"],"7PHCIN":["Cancel removal"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8ZsakT":["Password"],"8pxhI8":["Please select a file."],"9l4qcT":["Drop a replacement here"],"9uI/rE":["Undo"],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AQbgNR":["Follow ",["targetUsername"]],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade+6d":["Live updates unavailable."],"CI50ct":["Upvoted"],"Cj24wt":["Registering…"],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"]," upvoted <1>",["1"],""],"ECiS12":["View dump →"],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GNSsCc":["Failed to create playlist"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GsRMX3":["Playlist not found"],"H8pzW+":["Failed to update avatar"],"HTLDA4":["Loading more…"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"ImOQa9":["Reply"],"J2eKUI":["File"],"Jd58Fo":["Hot"],"K/F6pa":["Saving…"],"K1JdNl":["Username already exists"],"KDGWg5":["Remove from playlist"],"LLyMkV":["Followed (",["0"],["1"],")"],"LPAv9E":[["days"],"d ago"],"Lld1jm":["Tell people about yourself…"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"Mq2B8E":[["mins"],"m ago"],"NR0xa9":["Tell the community what makes this worth their time..."],"Nn4kr3":["+ New dump"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"PiH3UR":["Copied!"],"Pwqkdw":["Loading…"],"Q6n4F4":["Refresh metadata"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"SBTElJ":["Searching…"],"Sxm8rQ":["Users"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TM1ZbA":["Playlists (",["0"],["1"],")"],"Tv9vbB":["Follow playlist"],"U7u3q+":["+ New"],"UOZith":["Failed to post"],"UTiUFs":["Fetching…"],"VnNJbN":["From playlists"],"VyTYmS":["Change avatar"],"WpXcBJ":["Nothing here yet."],"WtkMN8":["All ",["0","plural",{"one":["#"," upvoted dump"],"other":["#"," upvoted dumps"]}]," loaded."],"XILg0L":["Invalid email address"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"Xi0Mn4":["← Back to profile"],"XnL+Eu":["No users match \\"",["q"],"\\"."],"YK1Dhc":["a post"],"YaSA2K":["Comment not found"],"YpkCca":["No followed playlists yet."],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZmD2o6":["Create & Add"],"aDvLhk":["Add a comment…"],"alBtu4":["Invalid or expired invite"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn+":["Loading playlist…"],"cILfnJ":["Remove file"],"cYP9Sb":["+ Playlist"],"cnGeoo":["Delete"],"d8DZWS":["Open search"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dSKHAa":["Invalid username or password"],"dTU6Wi":["Password must be at most 128 characters"],"dbc28f":["Why are you dumping this?"],"eFSqvc":["Failed to post reply"],"ePK91l":["Edit"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"en9o7K":["Failed to post comment"],"f/akpP":["Max 50 MB"],"fGxPOv":["Add a bio…"],"fI+mNw":["Playlists"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gLfZlz":["Add to playlist"],"gjJ+sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hYgDIe":["Create"],"he3ygx":["Copy"],"iDNBZe":["Notifications"],"ijVyoK":["Could not reach the server. Please try again."],"isRobC":["New"],"jGrTH0":["Not authenticated"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"kLttbL":["Registration failed"],"kYYCil":["File too large (max 50 MB)"],"l3JaOO":["Cannot edit a deleted comment"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr/":["Delete this comment?"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx/78":["Follow some public playlists to see their dumps here."],"qvFa8r":["public"],"rCbqPX":["This invite link is missing, expired, or already used."],"rg9pXu":["Search failed"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sBZMWb":["Invalid URL"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"shHs8T":["Enter a query to search."],"siMTjB":["File content is not a recognised image (JPEG, PNG, GIF, WebP)"],"smeBfS":["Invalid invite"],"tfDRzk":["Save"],"tqKwXl":["Username must be 1–32 characters and contain only letters, numbers, or underscores"],"u1lDX2":["Fetching preview…"],"u4pkXs":["Invite already used"],"uD0qXQ":["Drop a file here"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vuosjb":["User menu"],"vwGkYB":["Password must be at least 8 characters"],"wXO4Tg":[["hrs"],"h ago"],"wbXKOv":["File too large (max 50 MB)."],"wdiqRH":["Admin access required"],"wixIgH":["Already have an account? <0>Log in"],"x4aBfU":["Dump not found"],"xEWkgZ":["← Back to all dumps"],"xOTzt5":["just now"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O/u":["Delete dump"],"y/0uwd":["Yesterday"],"y6sq5j":["Following"],"yA/6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"yz7wBu":["Close"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', +); diff --git a/src/locales/en.po b/src/locales/en.po index fcd7021..df5af77 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -66,7 +66,7 @@ msgstr "← Back to all dumps" #: src/pages/UserDumps.tsx:61 #: src/pages/UserPlaylists.tsx:352 -#: src/pages/UserUpvoted.tsx:130 +#: src/pages/UserUpvoted.tsx:133 msgid "← Back to profile" msgstr "← Back to profile" @@ -74,7 +74,7 @@ msgstr "← Back to profile" msgid "+ Invite someone" msgstr "+ Invite someone" -#: src/components/AppHeader.tsx:63 +#: src/components/AppHeader.tsx:67 msgid "+ New" msgstr "+ New" @@ -83,6 +83,7 @@ msgstr "+ New" msgid "+ New dump" msgstr "+ New dump" +#: src/components/NewPlaylistForm.tsx:30 #: src/components/PlaylistMembershipPanel.tsx:72 msgid "+ New playlist" msgstr "+ New playlist" @@ -146,7 +147,7 @@ msgid "Add email…" msgstr "Add email…" #: src/components/AddToPlaylistModal.tsx:64 -#: src/components/DumpCreateModal.tsx:262 +#: src/components/DumpCreateModal.tsx:275 msgid "Add to playlist" msgstr "Add to playlist" @@ -160,7 +161,7 @@ msgid "All {0, plural, one {# dump} other {# dumps}} loaded." msgstr "All {0, plural, one {# dump} other {# dumps}} loaded." #. placeholder {0}: votes.length -#: src/pages/UserUpvoted.tsx:184 +#: src/pages/UserUpvoted.tsx:187 msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded." msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded." @@ -177,7 +178,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may #: src/components/CommentThread.tsx:353 #: src/components/CommentThread.tsx:483 #: src/components/ConfirmModal.tsx:32 -#: src/components/DumpCreateModal.tsx:394 +#: src/components/DumpCreateModal.tsx:408 #: src/components/PlaylistCreateForm.tsx:105 #: src/pages/DumpEdit.tsx:288 #: src/pages/PlaylistDetail.tsx:672 @@ -278,7 +279,7 @@ msgstr "Delete this playlist? This cannot be undone." msgid "Description (optional)" msgstr "Description (optional)" -#: src/components/DumpCreateModal.tsx:439 +#: src/components/DumpCreateModal.tsx:453 msgid "Done" msgstr "Done" @@ -290,7 +291,7 @@ msgstr "Drop a file here" msgid "Drop a replacement here" msgstr "Drop a replacement here" -#: src/components/DumpCreateModal.tsx:405 +#: src/components/DumpCreateModal.tsx:419 msgid "Dump it" msgstr "Dump it" @@ -298,7 +299,7 @@ msgstr "Dump it" #~ msgid "Dump not found" #~ msgstr "Dump not found" -#: src/components/DumpCreateModal.tsx:416 +#: src/components/DumpCreateModal.tsx:430 msgid "Dumped!" msgstr "Dumped!" @@ -372,7 +373,7 @@ msgstr "Failed to generate invite" msgid "Failed to load" msgstr "Failed to load" -#: src/components/DumpCreateModal.tsx:300 +#: src/components/DumpCreateModal.tsx:313 msgid "Failed to post" msgstr "Failed to post" @@ -400,15 +401,15 @@ msgstr "Failed to save edit" msgid "Failed to update avatar" msgstr "Failed to update avatar" -#: src/components/DumpCreateModal.tsx:333 +#: src/components/DumpCreateModal.tsx:347 msgid "Fetching preview…" msgstr "Fetching preview…" -#: src/components/DumpCreateModal.tsx:403 +#: src/components/DumpCreateModal.tsx:417 msgid "Fetching…" msgstr "Fetching…" -#: src/components/DumpCreateModal.tsx:293 +#: src/components/DumpCreateModal.tsx:306 #: src/components/FileDropZone.tsx:31 msgid "File" msgstr "File" @@ -425,7 +426,7 @@ msgstr "File" #~ msgid "File too large (max 50 MB)" #~ msgstr "File too large (max 50 MB)" -#: src/components/DumpCreateModal.tsx:187 +#: src/components/DumpCreateModal.tsx:200 msgid "File too large (max 50 MB)." msgstr "File too large (max 50 MB)." @@ -442,11 +443,11 @@ msgstr "Follow {targetUsername}" msgid "Follow playlist" msgstr "Follow playlist" -#: src/pages/index/FollowedFeed.tsx:359 +#: src/pages/index/FollowedFeed.tsx:358 msgid "Follow some public playlists to see their dumps here." msgstr "Follow some public playlists to see their dumps here." -#: src/pages/index/FollowedFeed.tsx:345 +#: src/pages/index/FollowedFeed.tsx:344 msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." @@ -469,11 +470,11 @@ msgstr "Following" #~ msgid "Forbidden" #~ msgstr "Forbidden" -#: src/pages/index/FollowedFeed.tsx:325 +#: src/pages/index/FollowedFeed.tsx:324 msgid "From people" msgstr "From people" -#: src/pages/index/FollowedFeed.tsx:332 +#: src/pages/index/FollowedFeed.tsx:331 msgid "From playlists" msgstr "From playlists" @@ -522,7 +523,7 @@ msgstr "just now" msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" -#: src/components/AppHeader.tsx:79 +#: src/components/AppHeader.tsx:83 msgid "Live updates unavailable." msgstr "Live updates unavailable." @@ -543,7 +544,7 @@ msgstr "Loading dump…" #: src/pages/UserDumps.tsx:111 #: src/pages/UserPlaylists.tsx:409 #: src/pages/UserPlaylists.tsx:436 -#: src/pages/UserUpvoted.tsx:180 +#: src/pages/UserUpvoted.tsx:183 msgid "Loading more…" msgstr "Loading more…" @@ -565,11 +566,11 @@ msgstr "Loading profile…" #: src/pages/Notifications.tsx:386 #: src/pages/UserDumps.tsx:50 #: src/pages/UserPlaylists.tsx:341 -#: src/pages/UserUpvoted.tsx:119 +#: src/pages/UserUpvoted.tsx:122 msgid "Loading…" msgstr "Loading…" -#: src/components/AppHeader.tsx:70 +#: src/components/AppHeader.tsx:74 #: src/pages/UserLogin.tsx:62 #: src/pages/UserLogin.tsx:91 msgid "Log in" @@ -600,10 +601,14 @@ msgstr "new" msgid "New" msgstr "New" -#: src/components/DumpCreateModal.tsx:262 +#: src/components/DumpCreateModal.tsx:275 msgid "New dump" msgstr "New dump" +#: src/components/NewPlaylistForm.tsx:34 +msgid "New playlist" +msgstr "New playlist" + #: src/pages/PlaylistDetail.tsx:783 msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." @@ -647,8 +652,8 @@ msgstr "No users match \"{q}\"." #: src/pages/Notifications.tsx:327 #: src/pages/UserDumps.tsx:92 #: src/pages/UserPublicProfile.tsx:930 -#: src/pages/UserPublicProfile.tsx:1049 -#: src/pages/UserUpvoted.tsx:151 +#: src/pages/UserPublicProfile.tsx:1047 +#: src/pages/UserUpvoted.tsx:154 msgid "Nothing here yet." msgstr "Nothing here yet." @@ -690,7 +695,7 @@ msgstr "Password (min. {0} characters)" #~ msgid "Playlist not found" #~ msgstr "Playlist not found" -#: src/components/AppHeader.tsx:46 +#: src/components/AppHeader.tsx:50 #: src/components/UserMenu.tsx:62 #: src/pages/Search.tsx:175 #: src/pages/UserPlaylists.tsx:366 @@ -703,7 +708,7 @@ msgstr "Playlists" msgid "Playlists ({0}{1})" msgstr "Playlists ({0}{1})" -#: src/components/DumpCreateModal.tsx:180 +#: src/components/DumpCreateModal.tsx:193 msgid "Please select a file." msgstr "Please select a file." @@ -728,7 +733,7 @@ msgstr "Posting…" msgid "private" msgstr "private" -#: src/components/DumpCreateModal.tsx:383 +#: src/components/DumpCreateModal.tsx:397 #: src/components/PlaylistCreateForm.tsx:94 #: src/pages/DumpEdit.tsx:274 #: src/pages/PlaylistDetail.tsx:737 @@ -740,7 +745,7 @@ msgstr "Private" msgid "public" msgstr "public" -#: src/components/DumpCreateModal.tsx:375 +#: src/components/DumpCreateModal.tsx:389 #: src/components/PlaylistCreateForm.tsx:87 #: src/pages/DumpEdit.tsx:267 #: src/pages/PlaylistDetail.tsx:730 @@ -820,7 +825,7 @@ msgstr "Search failed" msgid "Searching…" msgstr "Searching…" -#: src/components/AppHeader.tsx:61 +#: src/components/AppHeader.tsx:65 msgid "Server unreachable" msgstr "Server unreachable" @@ -836,7 +841,7 @@ msgstr "Submit search" msgid "Tell people about yourself…" msgstr "Tell people about yourself…" -#: src/components/DumpCreateModal.tsx:363 +#: src/components/DumpCreateModal.tsx:377 #: src/pages/DumpEdit.tsx:256 msgid "Tell the community what makes this worth their time..." msgstr "Tell the community what makes this worth their time..." @@ -877,11 +882,11 @@ msgstr "Unfollow playlist" msgid "Upload failed" msgstr "Upload failed" -#: src/components/DumpCreateModal.tsx:404 +#: src/components/DumpCreateModal.tsx:418 msgid "Uploading…" msgstr "Uploading…" -#: src/pages/UserUpvoted.tsx:147 +#: src/pages/UserUpvoted.tsx:150 msgid "Upvoted" msgstr "Upvoted" @@ -891,12 +896,12 @@ msgstr "Upvoted" msgid "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})" -#: src/components/DumpCreateModal.tsx:309 +#: src/components/DumpCreateModal.tsx:322 #: src/pages/DumpEdit.tsx:221 msgid "URL" msgstr "URL" -#: src/components/DumpCreateModal.tsx:164 +#: src/components/DumpCreateModal.tsx:176 msgid "URL is required." msgstr "URL is required." @@ -923,15 +928,15 @@ msgstr "Users" #: src/pages/UserPublicProfile.tsx:878 #: src/pages/UserPublicProfile.tsx:948 -#: src/pages/UserPublicProfile.tsx:1076 +#: src/pages/UserPublicProfile.tsx:1074 msgid "View all →" msgstr "View all →" -#: src/components/DumpCreateModal.tsx:418 +#: src/components/DumpCreateModal.tsx:432 msgid "View dump →" msgstr "View dump →" -#: src/components/DumpCreateModal.tsx:356 +#: src/components/DumpCreateModal.tsx:370 #: src/pages/DumpEdit.tsx:250 msgid "Why are you dumping this?" msgstr "Why are you dumping this?" diff --git a/src/locales/fr.po b/src/locales/fr.po index c9f47e9..b01c891 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -7,6 +7,11 @@ msgstr "" "X-Generator: @lingui/cli\n" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" #: src/components/CommentThread.tsx:170 msgid "[deleted]" @@ -61,7 +66,7 @@ msgstr "← Retour à toutes les recos" #: src/pages/UserDumps.tsx:61 #: src/pages/UserPlaylists.tsx:352 -#: src/pages/UserUpvoted.tsx:130 +#: src/pages/UserUpvoted.tsx:133 msgid "← Back to profile" msgstr "← Retour au profil" @@ -69,7 +74,7 @@ msgstr "← Retour au profil" msgid "+ Invite someone" msgstr "+ Inviter quelqu'un" -#: src/components/AppHeader.tsx:63 +#: src/components/AppHeader.tsx:67 msgid "+ New" msgstr "+ Nouveau" @@ -78,6 +83,7 @@ msgstr "+ Nouveau" msgid "+ New dump" msgstr "+ Nouvelle reco" +#: src/components/NewPlaylistForm.tsx:30 #: src/components/PlaylistMembershipPanel.tsx:72 msgid "+ New playlist" msgstr "+ Nouvelle collection" @@ -141,7 +147,7 @@ msgid "Add email…" msgstr "Ajouter un e-mail…" #: src/components/AddToPlaylistModal.tsx:64 -#: src/components/DumpCreateModal.tsx:262 +#: src/components/DumpCreateModal.tsx:275 msgid "Add to playlist" msgstr "Ajouter à la collection" @@ -151,7 +157,7 @@ msgid "All {0, plural, one {# dump} other {# dumps}} loaded." msgstr "Toutes les {0, plural, one {# reco} other {# recos}} chargées." #. placeholder {0}: votes.length -#: src/pages/UserUpvoted.tsx:184 +#: src/pages/UserUpvoted.tsx:187 msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded." msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} chargées." @@ -168,7 +174,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo #: src/components/CommentThread.tsx:353 #: src/components/CommentThread.tsx:483 #: src/components/ConfirmModal.tsx:32 -#: src/components/DumpCreateModal.tsx:394 +#: src/components/DumpCreateModal.tsx:408 #: src/components/PlaylistCreateForm.tsx:105 #: src/pages/DumpEdit.tsx:288 #: src/pages/PlaylistDetail.tsx:672 @@ -261,7 +267,7 @@ msgstr "Supprimer cette collection ? Cette action est irréversible." msgid "Description (optional)" msgstr "Description (facultatif)" -#: src/components/DumpCreateModal.tsx:439 +#: src/components/DumpCreateModal.tsx:453 msgid "Done" msgstr "Terminé" @@ -273,11 +279,11 @@ msgstr "Déposez un fichier ici" msgid "Drop a replacement here" msgstr "Déposez un fichier de remplacement ici" -#: src/components/DumpCreateModal.tsx:405 +#: src/components/DumpCreateModal.tsx:419 msgid "Dump it" msgstr "Recommander" -#: src/components/DumpCreateModal.tsx:416 +#: src/components/DumpCreateModal.tsx:430 msgid "Dumped!" msgstr "Recommandé !" @@ -351,7 +357,7 @@ msgstr "Impossible de générer une invitation" msgid "Failed to load" msgstr "Chargement échoué" -#: src/components/DumpCreateModal.tsx:300 +#: src/components/DumpCreateModal.tsx:313 msgid "Failed to post" msgstr "Publication échouée" @@ -379,20 +385,20 @@ msgstr "Impossible d'enregistrer la modification" msgid "Failed to update avatar" msgstr "Impossible de mettre à jour l'avatar" -#: src/components/DumpCreateModal.tsx:333 +#: src/components/DumpCreateModal.tsx:347 msgid "Fetching preview…" msgstr "Récupération de l'aperçu…" -#: src/components/DumpCreateModal.tsx:403 +#: src/components/DumpCreateModal.tsx:417 msgid "Fetching…" msgstr "Récupération…" -#: src/components/DumpCreateModal.tsx:293 +#: src/components/DumpCreateModal.tsx:306 #: src/components/FileDropZone.tsx:31 msgid "File" msgstr "Fichier" -#: src/components/DumpCreateModal.tsx:187 +#: src/components/DumpCreateModal.tsx:200 msgid "File too large (max 50 MB)." msgstr "Fichier trop volumineux (max 50 Mo)." @@ -409,11 +415,11 @@ msgstr "Suivre {targetUsername}" msgid "Follow playlist" msgstr "Suivre la collection" -#: src/pages/index/FollowedFeed.tsx:359 +#: src/pages/index/FollowedFeed.tsx:358 msgid "Follow some public playlists to see their dumps here." msgstr "Suivez des collections publiques pour voir leurs recos ici." -#: src/pages/index/FollowedFeed.tsx:345 +#: src/pages/index/FollowedFeed.tsx:344 msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." @@ -432,11 +438,11 @@ msgstr "Suivies ({0}{1})" msgid "Following" msgstr "Abonné" -#: src/pages/index/FollowedFeed.tsx:325 +#: src/pages/index/FollowedFeed.tsx:324 msgid "From people" msgstr "De personnes" -#: src/pages/index/FollowedFeed.tsx:332 +#: src/pages/index/FollowedFeed.tsx:331 msgid "From playlists" msgstr "De collections" @@ -464,7 +470,7 @@ msgstr "à l'instant" msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" -#: src/components/AppHeader.tsx:79 +#: src/components/AppHeader.tsx:83 msgid "Live updates unavailable." msgstr "Mises à jour en direct indisponibles." @@ -485,7 +491,7 @@ msgstr "Chargement de la reco…" #: src/pages/UserDumps.tsx:111 #: src/pages/UserPlaylists.tsx:409 #: src/pages/UserPlaylists.tsx:436 -#: src/pages/UserUpvoted.tsx:180 +#: src/pages/UserUpvoted.tsx:183 msgid "Loading more…" msgstr "Chargement…" @@ -507,11 +513,11 @@ msgstr "Chargement du profil…" #: src/pages/Notifications.tsx:386 #: src/pages/UserDumps.tsx:50 #: src/pages/UserPlaylists.tsx:341 -#: src/pages/UserUpvoted.tsx:119 +#: src/pages/UserUpvoted.tsx:122 msgid "Loading…" msgstr "Chargement…" -#: src/components/AppHeader.tsx:70 +#: src/components/AppHeader.tsx:74 #: src/pages/UserLogin.tsx:62 #: src/pages/UserLogin.tsx:91 msgid "Log in" @@ -542,10 +548,14 @@ msgstr "nouveau" msgid "New" msgstr "Nouveau" -#: src/components/DumpCreateModal.tsx:262 +#: src/components/DumpCreateModal.tsx:275 msgid "New dump" msgstr "Nouvelle reco" +#: src/components/NewPlaylistForm.tsx:34 +msgid "New playlist" +msgstr "Nouvelle collection" + #: src/pages/PlaylistDetail.tsx:783 msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." @@ -585,8 +595,8 @@ msgstr "Aucun utilisateur ne correspond à « {q} »." #: src/pages/Notifications.tsx:327 #: src/pages/UserDumps.tsx:92 #: src/pages/UserPublicProfile.tsx:930 -#: src/pages/UserPublicProfile.tsx:1049 -#: src/pages/UserUpvoted.tsx:151 +#: src/pages/UserPublicProfile.tsx:1047 +#: src/pages/UserUpvoted.tsx:154 msgid "Nothing here yet." msgstr "Rien ici pour l'instant." @@ -616,7 +626,7 @@ msgstr "Mot de passe" msgid "Password (min. {0} characters)" msgstr "Mot de passe (min. {0} caractères)" -#: src/components/AppHeader.tsx:46 +#: src/components/AppHeader.tsx:50 #: src/components/UserMenu.tsx:62 #: src/pages/Search.tsx:175 #: src/pages/UserPlaylists.tsx:366 @@ -629,7 +639,7 @@ msgstr "Collections" msgid "Playlists ({0}{1})" msgstr "Collections ({0}{1})" -#: src/components/DumpCreateModal.tsx:180 +#: src/components/DumpCreateModal.tsx:193 msgid "Please select a file." msgstr "Veuillez sélectionner un fichier." @@ -654,7 +664,7 @@ msgstr "Publication…" msgid "private" msgstr "privé" -#: src/components/DumpCreateModal.tsx:383 +#: src/components/DumpCreateModal.tsx:397 #: src/components/PlaylistCreateForm.tsx:94 #: src/pages/DumpEdit.tsx:274 #: src/pages/PlaylistDetail.tsx:737 @@ -666,7 +676,7 @@ msgstr "Privé" msgid "public" msgstr "public" -#: src/components/DumpCreateModal.tsx:375 +#: src/components/DumpCreateModal.tsx:389 #: src/components/PlaylistCreateForm.tsx:87 #: src/pages/DumpEdit.tsx:267 #: src/pages/PlaylistDetail.tsx:730 @@ -746,7 +756,7 @@ msgstr "Recherche échouée" msgid "Searching…" msgstr "Recherche…" -#: src/components/AppHeader.tsx:61 +#: src/components/AppHeader.tsx:65 msgid "Server unreachable" msgstr "Serveur inaccessible" @@ -762,7 +772,7 @@ msgstr "Lancer la recherche" msgid "Tell people about yourself…" msgstr "Parlez de vous…" -#: src/components/DumpCreateModal.tsx:363 +#: src/components/DumpCreateModal.tsx:377 #: src/pages/DumpEdit.tsx:256 msgid "Tell the community what makes this worth their time..." msgstr "Dites à la communauté pourquoi ça vaut le coup…" @@ -799,11 +809,11 @@ msgstr "Ne plus suivre la collection" msgid "Upload failed" msgstr "Envoi échoué" -#: src/components/DumpCreateModal.tsx:404 +#: src/components/DumpCreateModal.tsx:418 msgid "Uploading…" msgstr "Envoi…" -#: src/pages/UserUpvoted.tsx:147 +#: src/pages/UserUpvoted.tsx:150 msgid "Upvoted" msgstr "Voté" @@ -813,12 +823,12 @@ msgstr "Voté" msgid "Upvoted ({0}{1})" msgstr "Votés ({0}{1})" -#: src/components/DumpCreateModal.tsx:309 +#: src/components/DumpCreateModal.tsx:322 #: src/pages/DumpEdit.tsx:221 msgid "URL" msgstr "URL" -#: src/components/DumpCreateModal.tsx:164 +#: src/components/DumpCreateModal.tsx:176 msgid "URL is required." msgstr "L'URL est obligatoire." @@ -837,15 +847,15 @@ msgstr "Utilisateurs" #: src/pages/UserPublicProfile.tsx:878 #: src/pages/UserPublicProfile.tsx:948 -#: src/pages/UserPublicProfile.tsx:1076 +#: src/pages/UserPublicProfile.tsx:1074 msgid "View all →" msgstr "Tout voir →" -#: src/components/DumpCreateModal.tsx:418 +#: src/components/DumpCreateModal.tsx:432 msgid "View dump →" msgstr "Voir la reco →" -#: src/components/DumpCreateModal.tsx:356 +#: src/components/DumpCreateModal.tsx:370 #: src/pages/DumpEdit.tsx:250 msgid "Why are you dumping this?" msgstr "Pourquoi recommandez-vous ça ?" diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index a0c59d8..0959f49 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { dumpUrl } from "../utils/urls.ts"; import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx"; @@ -190,7 +190,9 @@ export function Dump() { if (dumpState.status === "loading") { return ( -

Loading dump…

+

+ Loading dump… +

); } @@ -315,7 +317,9 @@ export function Dump() { Edit )} - ← Back to all dumps + + ← Back to all dumps +
{/* Comments */} diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index a7cf44f..fa5e6b0 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { API_URL } from "../config/api.ts"; +import { API_URL, VALIDATION } from "../config/api.ts"; import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts"; import { deserializeDump, parseAPIResponse } from "../model.ts"; import { useRequiredAuth } from "../hooks/useAuth.ts"; @@ -65,7 +65,9 @@ export function DumpEdit() { }, [selectedDump, token]); const handleSave = async () => { - if (state.status !== "loaded") return; + if ( + state.status !== "loaded" || comment.length > VALIDATION.DUMP_COMMENT_MAX + ) return; let res: Response; @@ -140,7 +142,9 @@ export function DumpEdit() { if (state.status === "loading") { return ( -

Loading dump…

+

+ Loading dump… +

); } @@ -177,7 +181,9 @@ export function DumpEdit() {
-

Editing

+

+ Editing +

{dump.title}

@@ -203,7 +209,9 @@ export function DumpEdit() { onClick={handleRefreshMetadata} disabled={refreshing} > - {refreshing ? Refreshing… : Refresh metadata} + {refreshing + ? Refreshing… + : Refresh metadata} )}
@@ -218,7 +226,9 @@ export function DumpEdit() { {dump.kind === "url" ? (
- +
@@ -287,7 +298,11 @@ export function DumpEdit() { Cancel -
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 02e7a7e..f622d02 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -83,9 +83,21 @@ export function Index() { ); const mainFetchDone = useRef(false); - const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot"; + const searchParams = new URLSearchParams(location.search); + const rawTab = searchParams.get("tab") ?? "hot"; const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot"; + // Web Share Target: Android share sheet navigates to /?share_url=... + const shareUrl = searchParams.get("share_url") ?? + searchParams.get("share_text") ?? ""; + + useEffect(() => { + if (!shareUrl) return; + // Clean share params from the URL so a refresh doesn't re-open the modal + const clean = tab !== "hot" ? `?tab=${tab}` : ""; + globalThis.history.replaceState({}, "", location.pathname + clean); + }, [shareUrl, tab, location.pathname]); + // ── Main feed fetch ── useEffect(() => { @@ -241,6 +253,7 @@ export function Index() {
} disableNew={dumpsState.status === "error"} + initialDumpUrl={shareUrl || undefined} />
diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index c7563c8..0956a2f 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { Link } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts"; @@ -315,7 +315,9 @@ export function Notifications() {
{state.status === "loading" && ( -

Loading…

+

+ Loading… +

)} {state.status === "error" && ( @@ -324,7 +326,9 @@ export function Notifications() { {state.status === "loaded" && state.items.length === 0 && (
🔕 -

Nothing here yet.

+

+ Nothing here yet. +

You'll be notified when someone follows your playlists, upvotes @@ -338,7 +342,11 @@ export function Notifications() { groupByDate(state.items).map(({ label, items }) => (

- {label === "Today" ? t`Today` : label === "Yesterday" ? t`Yesterday` : t`Earlier`} + {label === "Today" + ? t`Today` + : label === "Yesterday" + ? t`Yesterday` + : t`Earlier`}

    {items.map((n) => ( @@ -383,7 +391,9 @@ export function Notifications() { onClick={loadMore} disabled={state.loadingMore} > - {state.loadingMore ? Loading… : Load more} + {state.loadingMore + ? Loading… + : Load more} )}
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 92d5e2b..bdaff78 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -6,9 +6,10 @@ import { useState, } from "react"; import { Link, useNavigate, useParams } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { API_URL } from "../config/api.ts"; +import { API_URL, VALIDATION } from "../config/api.ts"; +import { CountedInput } from "../components/CountedInput.tsx"; import type { PlaylistWithDumps, RawDump, @@ -527,7 +528,10 @@ export function PlaylistDetail() { }; const handleEditSave = async () => { - if (!playlistId || state.status !== "loaded") return; + if ( + !playlistId || state.status !== "loaded" || + editDescription.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX + ) return; setEditSaving(true); setEditError(null); try { @@ -587,7 +591,9 @@ export function PlaylistDetail() { if (state.status === "loading") { return ( -

Loading playlist…

+

+ Loading playlist… +

); } @@ -649,17 +655,19 @@ export function PlaylistDetail() { {editOpen ? (
- setEditTitle(e.target.value)} autoFocus + maxLength={VALIDATION.PLAYLIST_TITLE_MAX} />
{visibleDumps.length === 0 - ?

No dumps in this playlist yet.

+ ? ( +

+ No dumps in this playlist yet. +

+ ) : (
e.preventDefault() - : undefined} + onDragOver={isOwner ? (e) => e.preventDefault() : undefined} > {visibleDumps.map((dump) => { const isActive = activeDumpIds.has(dump.id); diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 92cc904..072b73a 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AppHeader } from "../components/AppHeader.tsx"; import { SearchBar } from "../components/SearchBar.tsx"; @@ -203,11 +203,15 @@ export function Search() { )} {state.status === "idle" && ( -

Enter a query to search.

+

+ Enter a query to search. +

)} {state.status === "loading" && ( -

Searching…

+

+ Searching… +

)} {state.status === "error" && ( @@ -236,10 +240,14 @@ export function Search() { )}
{state.dumps.loadingMore && ( -

Loading more…

+

+ Loading more… +

)} {!state.dumps.hasMore && state.dumps.items.length > 0 && ( -

You've reached the end.

+

+ You've reached the end. +

)} )} @@ -270,7 +278,11 @@ export function Search() { {state.status === "loaded" && tab === "playlists" && ( state.playlists.length === 0 - ?

{t`No playlists match "${q}".`}

+ ? ( +

+ {t`No playlists match "${q}".`} +

+ ) : (
    {state.playlists.map((p) => ( diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index df66cdb..39d34e9 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { t } from "@lingui/core/macro" -import { Trans, Plural } from "@lingui/react/macro"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; import { Link, useParams } from "react-router"; import { useAuth } from "../hooks/useAuth.ts"; @@ -47,7 +47,9 @@ export function UserDumps() { if (state.status === "loading") { return ( -

    Loading…

    +

    + Loading… +

    ); } @@ -89,7 +91,11 @@ export function UserDumps() { )} {dumps.length === 0 - ?

    Nothing here yet.

    + ? ( +

    + Nothing here yet. +

    + ) : (
      {dumps.map((dump) => ( @@ -108,10 +114,18 @@ export function UserDumps() { )}
      - {loadingMore &&

      Loading more…

      } + {loadingMore && ( +

      + Loading more… +

      + )} {!hasMore && dumps.length > 0 && (

      - All loaded. + + All + {" "} + loaded. +

      )} diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx index 5fdd2ff..82acdd4 100644 --- a/src/pages/UserLogin.tsx +++ b/src/pages/UserLogin.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import type { SubmitEvent } from "react"; import { useNavigate } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../config/api.ts"; @@ -59,7 +59,9 @@ export function UserLogin() { return (
      -

      Log in

      +

      + Log in +

      {state.status === "error" && ( diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index c58879e..eb29a57 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -6,7 +6,7 @@ import { useState, } from "react"; import { Link, useParams } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; @@ -338,7 +338,9 @@ export function UserPlaylists() { if (state.status === "loading") { return ( -

      Loading…

      +

      + Loading… +

      ); } @@ -384,12 +386,17 @@ export function UserPlaylists() {

      - Created ({created.items.length}{created.hasMore ? "+" : ""}) + Created ({created.items.length} + {created.hasMore ? "+" : ""})

      {created.items.length === 0 - ?

      No playlists yet.

      + ? ( +

      + No playlists yet. +

      + ) : (
        {created.items.map((p) => ( @@ -406,7 +413,9 @@ export function UserPlaylists() { )}
        {created.loadingMore && ( -

        Loading more…

        +

        + Loading more… +

        )} @@ -414,7 +423,8 @@ export function UserPlaylists() {

        - Followed ({followed.items.length}{followed.hasMore ? "+" : ""}) + Followed ({followed.items.length} + {followed.hasMore ? "+" : ""})

        @@ -433,7 +443,9 @@ export function UserPlaylists() { )}
        {followed.loadingMore && ( -

        Loading more…

        +

        + Loading more… +

        )} diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index b71a431..6f259c8 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -6,10 +6,10 @@ import React, { useState, } from "react"; import { Link, useNavigate, useParams } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; +import { API_URL, DEFAULT_PAGE_SIZE, VALIDATION } from "../config/api.ts"; import type { Dump, PaginatedData, PublicUser } from "../model.ts"; import { deserializeAuthResponse, @@ -89,7 +89,9 @@ function InviteButton() { - {error && } + {error && ( + + )}
        ); } @@ -550,7 +552,10 @@ export function UserPublicProfile() { }; const handleDescSave = async () => { - if (state.status !== "loaded") return; + if ( + state.status !== "loaded" || + descDraft.length > VALIDATION.USER_DESCRIPTION_MAX + ) return; setDescSaving(true); setDescError(null); try { @@ -587,7 +592,9 @@ export function UserPublicProfile() { if (state.status === "loading") { return ( -

        Loading profile…

        +

        + Loading profile… +

        ); } @@ -689,7 +696,9 @@ export function UserPublicProfile() { className="profile-email-btn profile-email-btn--save" disabled={emailSaving || !emailDraft.trim()} > - {emailSaving ? Saving… : Save} + {emailSaving + ? Saving… + : Save} @@ -842,7 +856,10 @@ export function UserPublicProfile() {

        - Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""}) + + Playlists ({playlists.items.length} + {playlists.hasMore ? "+" : ""}) +

        {isOwnProfile && ( {playlists.items.length === 0 - ?

        No playlists yet.

        + ? ( +

        + No playlists yet. +

        + ) : (
          {playlists.items.map((p) => ( @@ -927,7 +948,11 @@ function DumpList( setCreateModalOpen(false)} /> )} {dumps.length === 0 - ?

          Nothing here yet.

          + ? ( +

          + Nothing here yet. +

          + ) : (
            {dumps.map((dump) => ( @@ -945,7 +970,9 @@ function DumpList(
          )} {dumps.length > 0 && ( - View all → + + View all → + )}
        ); @@ -998,9 +1025,7 @@ function UpvotedDumpList( useEffect(() => { if (!profileUserId || !isOwnProfile) return; if (prevMyVotesRef.current === null) { - // setVotedIds must fire here alongside prevMyVotesRef mutation; render-phase - // isn't possible because startFading/cancelFading (below) are also setState - // calls that cannot be invoked during render. + // setVotedIds + prevMyVotesRef must be co-located to stay consistent. // eslint-disable-next-line react-hooks/set-state-in-effect setVotedIds(new Set(wsMyVotes)); prevMyVotesRef.current = new Set(wsMyVotes); @@ -1021,8 +1046,8 @@ function UpvotedDumpList( const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; if (action === "remove") { - // setVotedIds + startFading must be coordinated in the same effect body - // to guarantee a single render — render-phase can't call startFading (setState). + // setVotedIds and startFading must fire together to avoid a render with + // stale votedIds between the two updates. // eslint-disable-next-line react-hooks/set-state-in-effect setVotedIds((prev) => { const n = new Set(prev); @@ -1046,7 +1071,11 @@ function UpvotedDumpList(

        {title}

        {visibleDumps.length === 0 - ?

        Nothing here yet.

        + ? ( +

        + Nothing here yet. +

        + ) : (
          {visibleDumps.map((dump) => { @@ -1073,7 +1102,9 @@ function UpvotedDumpList(
        )} {visibleDumps.length > 0 && ( - View all → + + View all → + )} ); diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx index bc018a6..ff096d4 100644 --- a/src/pages/UserRegister.tsx +++ b/src/pages/UserRegister.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import type { SubmitEvent } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL, VALIDATION } from "../config/api.ts"; @@ -91,7 +91,9 @@ export function UserRegister() { if (tokenState.status === "checking") { return ( -

        Checking invite…

        +

        + Checking invite… +

        ); } @@ -112,7 +114,9 @@ export function UserRegister() { return (
        -

        Register

        +

        + Register +

        {formState.status === "error" && ( @@ -126,6 +130,7 @@ export function UserRegister() { required pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`} title={t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`} + maxLength={VALIDATION.USERNAME_MAX} disabled={formState.status === "submitting"} autoFocus /> @@ -157,7 +162,9 @@ export function UserRegister() {

        - Already have an account? Log in + + Already have an account? Log in +

        diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index c1d2034..d060d97 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Plural, Trans } from "@lingui/react/macro"; import { API_URL } from "../config/api.ts"; @@ -69,6 +69,7 @@ export function UserUpvoted() { useEffect(() => { if (!profileUserId || me?.id !== profileUserId) return; if (prevMyVotesRef.current === null) { + // setVotedIds + prevMyVotesRef must be co-located to stay consistent. // eslint-disable-next-line react-hooks/set-state-in-effect setVotedIds(new Set(myVotes)); prevMyVotesRef.current = new Set(myVotes); @@ -87,6 +88,8 @@ export function UserUpvoted() { if (voterId !== profileUserId) return; if (action === "remove") { + // setVotedIds and startFading must fire together to avoid a render with + // stale votedIds between the two updates. // eslint-disable-next-line react-hooks/set-state-in-effect setVotedIds((prev) => { const n = new Set(prev); @@ -116,7 +119,9 @@ export function UserUpvoted() { if (state.status === "loading") { return ( -

        Loading…

        +

        + Loading… +

        ); } @@ -148,7 +153,11 @@ export function UserUpvoted() { /> {visibleDumps.length === 0 - ?

        Nothing here yet.

        + ? ( +

        + Nothing here yet. +

        + ) : (
          {visibleDumps.map((dump) => { @@ -177,11 +186,21 @@ export function UserUpvoted() {
          {loadingMore && ( -

          Loading more…

          +

          + Loading more… +

          )} {!hasMore && visibleDumps.length > 0 && (

          - All loaded. + + All{" "} + {" "} + loaded. +

          )} diff --git a/src/pages/index/FollowedFeed.tsx b/src/pages/index/FollowedFeed.tsx index 3068118..ad243e5 100644 --- a/src/pages/index/FollowedFeed.tsx +++ b/src/pages/index/FollowedFeed.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { DumpCard } from "../../components/DumpCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx"; @@ -71,7 +71,11 @@ function FollowedSubFeed({ const sentinelRef = useInfiniteScroll(onLoadMore, enabled); if (state.status === "loading") { - return

          Loading…

          ; + return ( +

          + Loading… +

          + ); } if (state.status === "error") { return ; @@ -100,7 +104,11 @@ function FollowedSubFeed({ ))}
        - {state.loadingMore &&

        Loading more…

        } + {state.loadingMore && ( +

        + Loading more… +

        + )} ); } @@ -219,8 +227,7 @@ export function FollowedFeed({ }) ); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]); + }, [token, usersState.status, playlistsState.status]); // Scroll save useScrollSave( diff --git a/src/pages/index/HotFeed.tsx b/src/pages/index/HotFeed.tsx index 2dbb423..560ed18 100644 --- a/src/pages/index/HotFeed.tsx +++ b/src/pages/index/HotFeed.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { DumpCard } from "../../components/DumpCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx"; @@ -26,10 +26,20 @@ export function HotFeed( [dumps], ); - if (loading) return

        Loading…

        ; + if (loading) { + return ( +

        + Loading… +

        + ); + } if (error) return ; if (sorted.length === 0) { - return

        No dumps yet. Be the first!

        ; + return ( +

        + No dumps yet. Be the first! +

        + ); } return ( @@ -49,9 +59,15 @@ export function HotFeed( ))}
      - {loadingMore &&

      Loading more…

      } + {loadingMore && ( +

      + Loading more… +

      + )} {!hasMore && sorted.length > 0 && ( -

      You've reached the end.

      +

      + You've reached the end. +

      )} ); diff --git a/src/pages/index/JournalFeed.tsx b/src/pages/index/JournalFeed.tsx index 90cb34f..95af50d 100644 --- a/src/pages/index/JournalFeed.tsx +++ b/src/pages/index/JournalFeed.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ErrorCard } from "../../components/ErrorCard.tsx"; import { @@ -38,10 +38,20 @@ export function JournalFeed( }); }, [dumps]); - if (loading) return

      Loading…

      ; + if (loading) { + return ( +

      + Loading… +

      + ); + } if (error) return ; if (tiered.length === 0) { - return

      No dumps yet. Be the first!

      ; + return ( +

      + No dumps yet. Be the first! +

      + ); } return ( @@ -62,9 +72,15 @@ export function JournalFeed( ))}
    - {loadingMore &&

    Loading more…

    } + {loadingMore && ( +

    + Loading more… +

    + )} {!hasMore && tiered.length > 0 && ( -

    You've reached the end.

    +

    + You've reached the end. +

    )} ); diff --git a/src/pages/index/NewFeed.tsx b/src/pages/index/NewFeed.tsx index 7d7d105..5ed29b3 100644 --- a/src/pages/index/NewFeed.tsx +++ b/src/pages/index/NewFeed.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { t } from "@lingui/core/macro" +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { DumpCard } from "../../components/DumpCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx"; @@ -26,10 +26,20 @@ export function NewFeed( [dumps], ); - if (loading) return

    Loading…

    ; + if (loading) { + return ( +

    + Loading… +

    + ); + } if (error) return ; if (sorted.length === 0) { - return

    No dumps yet. Be the first!

    ; + return ( +

    + No dumps yet. Be the first! +

    + ); } return ( @@ -49,9 +59,15 @@ export function NewFeed( ))}
- {loadingMore &&

Loading more…

} + {loadingMore && ( +

+ Loading more… +

+ )} {!hasMore && sorted.length > 0 && ( -

You've reached the end.

+

+ You've reached the end. +

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