playing && setControlsVisible(false)}
>
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index a8ce0c3..031b640 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -1,5 +1,6 @@
import { type ReactNode, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
+import { t } from "@lingui/core/macro";
interface ModalProps {
title: string;
@@ -41,7 +42,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
type="button"
className="modal-close-btn"
onClick={onClose}
- aria-label="Close"
+ aria-label={t`Close`}
>
ā
diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx
index b8e0511..6e45bc0 100644
--- a/src/components/NotificationBell.tsx
+++ b/src/components/NotificationBell.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
+import { t } from "@lingui/core/macro";
import { useWS } from "../hooks/useWS.ts";
export function NotificationBell() {
@@ -18,12 +19,15 @@ export function NotificationBell() {
if (animatingRef.current) return;
animatingRef.current = true;
- setRinging(true);
- const t = setTimeout(() => {
+ const tStart = setTimeout(() => setRinging(true), 0);
+ const tEnd = setTimeout(() => {
setRinging(false);
animatingRef.current = false;
}, 700);
- return () => clearTimeout(t);
+ return () => {
+ clearTimeout(tStart);
+ clearTimeout(tEnd);
+ };
}, [lastNotification]);
return (
@@ -33,11 +37,9 @@ export function NotificationBell() {
ringing ? " notification-bell--ringing" : ""
}`}
onClick={() => navigate("/notifications")}
- aria-label={`Notifications${
- unreadNotificationCount > 0
- ? ` (${unreadNotificationCount} unread)`
- : ""
- }`}
+ aria-label={unreadNotificationCount > 0
+ ? t`Notifications (${unreadNotificationCount} unread)`
+ : t`Notifications`}
>
š
{unreadNotificationCount > 0 && (
diff --git a/src/components/PageError.tsx b/src/components/PageError.tsx
index 7928457..3f6139f 100644
--- a/src/components/PageError.tsx
+++ b/src/components/PageError.tsx
@@ -1,18 +1,20 @@
import type { ReactNode } from "react";
+import { t } from "@lingui/core/macro";
import { PageShell } from "./PageShell.tsx";
import { ErrorCard } from "./ErrorCard.tsx";
export function PageError(
- { title = "Something went wrong", message, actions }: {
+ { title, message, actions }: {
title?: string;
message: string;
actions?: ReactNode;
},
) {
+ const resolvedTitle = title ?? t`Something went wrong`;
return (
-
+
);
diff --git a/src/components/PlaylistCard.tsx b/src/components/PlaylistCard.tsx
index 0a5afe7..b318cf7 100644
--- a/src/components/PlaylistCard.tsx
+++ b/src/components/PlaylistCard.tsx
@@ -1,4 +1,6 @@
import { Link, useNavigate } from "react-router";
+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";
import { relativeTime } from "../utils/relativeTime.ts";
@@ -66,7 +68,7 @@ export function PlaylistCard(
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
- {playlist.isPublic ? "public" : "private"}
+ {playlist.isPublic ?
public :
private }
{playlist.ownerUsername && !isOwner && (
- {playlist.dumpCount}{" "}
- {playlist.dumpCount === 1 ? "dump" : "dumps"}
+
)}
@@ -99,7 +104,7 @@ export function PlaylistCard(
e.stopPropagation();
onDelete();
}}
- aria-label="Delete playlist"
+ aria-label={t`Delete playlist`}
>
ā
diff --git a/src/components/PlaylistCreateForm.tsx b/src/components/PlaylistCreateForm.tsx
index 2c98f8b..5c04eaf 100644
--- a/src/components/PlaylistCreateForm.tsx
+++ b/src/components/PlaylistCreateForm.tsx
@@ -1,4 +1,6 @@
import { useState } from "react";
+import { t } from "@lingui/core/macro"
+import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
@@ -54,7 +56,7 @@ export function PlaylistCreateForm(
}
onCreated(playlist);
} catch {
- setError("Failed to create playlist");
+ setError(t`Failed to create playlist`);
} finally {
setSubmitting(false);
}
@@ -64,14 +66,14 @@ export function PlaylistCreateForm(
- {error && No playlists yet.
+ ?
{memberships.map((m) => (
@@ -43,7 +44,7 @@ export function PlaylistMembershipPanel({
{!m.playlist.isPublic && (
- private
+ private
)}
@@ -68,7 +69,7 @@ export function PlaylistMembershipPanel({
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
- + New playlist
+ + New playlist
)}
>
diff --git a/src/components/RichContentCard.tsx b/src/components/RichContentCard.tsx
index a3ad18f..3e71e96 100644
--- a/src/components/RichContentCard.tsx
+++ b/src/components/RichContentCard.tsx
@@ -47,6 +47,7 @@ export default function RichContentCard(
className="rich-content-thumbnail-btn"
onClick={() =>
play({
+ kind: "embed",
embedUrl: richContent.embedUrl!,
title: richContent.title,
type: richContent.type,
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx
index 2edefa8..de53cff 100644
--- a/src/components/SearchBar.tsx
+++ b/src/components/SearchBar.tsx
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
+import { t } from "@lingui/core/macro";
interface SearchBarProps {
collapsible?: boolean;
@@ -15,7 +16,7 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
useEffect(() => {
if (collapsible && expanded) inputRef.current?.focus();
- }, [expanded]);
+ }, [expanded, collapsible]);
function handleIconClick() {
if (!collapsible) return;
@@ -57,17 +58,17 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
ref={inputRef}
type="search"
className="search-bar-input"
- placeholder="Search dumps, users, playlistsā¦"
+ placeholder={t`Search dumps, users, playlistsā¦`}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
- aria-label="Search"
+ aria-label={t`Search`}
tabIndex={expanded ? 0 : -1}
/>
š
diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx
index 87cdd37..3f83df5 100644
--- a/src/components/TextEditor.tsx
+++ b/src/components/TextEditor.tsx
@@ -7,6 +7,7 @@ import {
useState,
} from "react";
import { EmojiPicker } from "frimousse";
+import { Trans } from "@lingui/react/macro";
import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
@@ -269,8 +270,8 @@ export const TextEditor = forwardRef(
// 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 6441650..ea94682 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
+import { t } from "@lingui/core/macro"
+import { Trans } from "@lingui/react/macro";
import { Avatar } from "./Avatar.tsx";
import type { User } from "../model.ts";
@@ -32,7 +34,7 @@ export function UserMenu({ user }: { user: User }) {
className="user-menu-trigger"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
- aria-label="User menu"
+ aria-label={t`User menu`}
>
setOpen(false)}
>
- Playlists
+ Playlists
)}
diff --git a/src/config/feedTabs.ts b/src/config/feedTabs.ts
new file mode 100644
index 0000000..9db1a96
--- /dev/null
+++ b/src/config/feedTabs.ts
@@ -0,0 +1,3 @@
+export const FEED_TABS = ["hot", "new", "journal", "followed"] as const;
+export type FeedTab = (typeof FEED_TABS)[number];
+export const VALID_TABS = new Set(FEED_TABS);
diff --git a/src/contexts/PlayerContext.ts b/src/contexts/PlayerContext.ts
index 7652fa9..dc27909 100644
--- a/src/contexts/PlayerContext.ts
+++ b/src/contexts/PlayerContext.ts
@@ -1,19 +1,43 @@
import { createContext } from "react";
-export interface PlayerItem {
- embedUrl: string;
- title?: string;
- type: string;
-}
+export type PlayerItem =
+ | { kind: "embed"; embedUrl: string; title?: string; type: string }
+ | { kind: "file"; fileUrl: string; mimeType: string; title?: string };
export interface PlayerContextValue {
+ // Playback state ā readable by any consumer
current: PlayerItem | null;
+ playing: boolean;
+ currentTime: number;
+ duration: number;
+
+ // Control ā callable by any consumer
play(item: PlayerItem): void;
stop(): void;
+ seekTo(time: number): void;
+ togglePlay(): void;
+
+ // Internal: GlobalPlayer registers MediaPlayer's imperative handles here
+ // so seekTo / togglePlay can reach into the actual media element.
+ seekRef: { current: ((t: number) => void) | null };
+ toggleRef: { current: (() => void) | null };
+
+ // Internal: GlobalPlayer calls these to push state back into the provider
+ onPlayStateChange(playing: boolean): void;
+ onTimeUpdate(time: number, duration: number): void;
}
export const PlayerContext = createContext({
current: null,
+ playing: false,
+ currentTime: 0,
+ duration: 0,
play: () => {},
stop: () => {},
+ seekTo: () => {},
+ togglePlay: () => {},
+ seekRef: { current: null },
+ toggleRef: { current: null },
+ onPlayStateChange: () => {},
+ onTimeUpdate: () => {},
});
diff --git a/src/contexts/PlayerProvider.tsx b/src/contexts/PlayerProvider.tsx
index 426708d..5edb7ce 100644
--- a/src/contexts/PlayerProvider.tsx
+++ b/src/contexts/PlayerProvider.tsx
@@ -1,12 +1,80 @@
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
export function PlayerProvider({ children }: { children: React.ReactNode }) {
const [current, setCurrent] = useState(null);
+ const [playing, setPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
- const play = setCurrent;
- const stop = useCallback(() => setCurrent(null), []);
- const value = useMemo(() => ({ current, play, stop }), [current, play, stop]);
+ // GlobalPlayer registers the active MediaPlayer's imperative handles here
+ const seekRef = useRef<((t: number) => void) | null>(null);
+ const toggleRef = useRef<(() => void) | null>(null);
+
+ // Suppresses stale timeupdate callbacks that fire between play() and the old
+ // MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
+ const suppressUpdates = useRef(false);
+
+ const play = useCallback((item: PlayerItem) => {
+ suppressUpdates.current = true;
+ setCurrent(item);
+ setCurrentTime(0);
+ setDuration(0);
+ setPlaying(false);
+ }, []);
+
+ const stop = useCallback(() => {
+ setCurrent(null);
+ setPlaying(false);
+ setCurrentTime(0);
+ setDuration(0);
+ }, []);
+
+ const seekTo = useCallback((t: number) => {
+ seekRef.current?.(t);
+ setCurrentTime(t); // optimistic ā prevents waveform jitter before timeupdate fires
+ }, []);
+
+ const togglePlay = useCallback(() => {
+ toggleRef.current?.();
+ }, []);
+
+ const onPlayStateChange = useCallback((p: boolean) => {
+ if (p) suppressUpdates.current = false;
+ setPlaying(p);
+ }, []);
+
+ const onTimeUpdate = useCallback((t: number, d: number) => {
+ if (suppressUpdates.current) return;
+ setCurrentTime(t);
+ setDuration(d);
+ }, []);
+
+ const value = useMemo(() => ({
+ current,
+ playing,
+ currentTime,
+ duration,
+ play,
+ stop,
+ seekTo,
+ togglePlay,
+ seekRef,
+ toggleRef,
+ onPlayStateChange,
+ onTimeUpdate,
+ }), [
+ current,
+ playing,
+ currentTime,
+ duration,
+ play,
+ stop,
+ seekTo,
+ togglePlay,
+ onPlayStateChange,
+ onTimeUpdate,
+ ]);
return (
diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx
index 3c4c6f1..ea3c7a2 100644
--- a/src/contexts/WSProvider.tsx
+++ b/src/contexts/WSProvider.tsx
@@ -30,12 +30,11 @@ import {
deserializePlaylist,
deserializePublicUser,
} from "../model.ts";
+import { t } from "@lingui/core/macro";
+import { useAuth } from "../hooks/useAuth.ts";
interface WSProviderProps {
children: ReactNode;
- token: string | null;
- userId: string | null;
- onForceLogout?: () => void;
}
const MAX_BACKOFF = 30_000;
@@ -61,13 +60,23 @@ function parseWSMessage(data: string): IncomingWSMessage | null {
}
}
-export function WSProvider(
- { children, token, userId, onForceLogout }: WSProviderProps,
-) {
+export function WSProvider({ children }: WSProviderProps) {
+ const { token, user, logout } = useAuth();
+ const userId = user?.id ?? null;
const [wsStatus, setWSStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const [wsErrorMessage, setWSErrorMessage] = useState(null);
+
+ // Reset status to "connecting" during render when token changes, rather than
+ // inside the effect (which would cause a cascading re-render).
+ const [prevToken, setPrevToken] = useState(token);
+ if (prevToken !== token) {
+ setPrevToken(token);
+ setWSStatus("connecting");
+ setWSErrorMessage(null);
+ }
+
const [onlineUsers, setOnlineUsers] = useState([]);
const [voteCounts, setVoteCounts] = useState>({});
const [myVotes, setMyVotes] = useState>(new Set());
@@ -94,10 +103,14 @@ export function WSProvider(
const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes);
const userIdRef = useRef(userId);
+ // Stable ref for logout so the effect doesn't reconnect when the function
+ // reference changes on re-renders.
+ const onForceLogoutRef = useRef(logout);
useLayoutEffect(() => {
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
userIdRef.current = userId;
+ onForceLogoutRef.current = logout;
});
const socketRef = useRef(null);
@@ -139,9 +152,6 @@ export function WSProvider(
let connectTimeout: ReturnType | null = null;
let everConnected = false;
- setWSStatus("connecting");
- setWSErrorMessage(null);
-
function connect() {
if (closed) return;
@@ -155,7 +165,7 @@ export function WSProvider(
if (ws.readyState !== WebSocket.CONNECTING) return;
setWSStatus("disconnected");
setWSErrorMessage(
- "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.",
+ t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
);
ws.close();
}, CONNECT_TIMEOUT);
@@ -327,7 +337,7 @@ export function WSProvider(
}
case "force_logout":
- onForceLogout?.();
+ onForceLogoutRef.current();
break;
case "error":
@@ -346,8 +356,8 @@ export function WSProvider(
setWSStatus("disconnected");
setWSErrorMessage(
everConnected
- ? "Live updates are temporarily disconnected. Trying to reconnect..."
- : "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.",
+ ? t`Live updates are temporarily disconnected. Trying to reconnectā¦`
+ : t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
);
reconnectTimer = setTimeout(() => {
backoff = Math.min(backoff * 2, MAX_BACKOFF);
diff --git a/src/hooks/useEmojiTrigger.ts b/src/hooks/useEmojiTrigger.ts
index 2d20b39..33f15a0 100644
--- a/src/hooks/useEmojiTrigger.ts
+++ b/src/hooks/useEmojiTrigger.ts
@@ -1,7 +1,7 @@
import { type RefObject, useCallback, useRef, useState } from "react";
// Trigger: ':' not preceded by a word character, followed by 1+ word chars
-const TRIGGER_RE = /(?(cacheKey, hydrateDump);
const [state, setState] = useState({ status: "loading" });
+ const [prevUsername, setPrevUsername] = useState(username);
+ if (prevUsername !== username) {
+ setPrevUsername(username);
+ setState({ status: "loading" });
+ }
const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) });
@@ -70,7 +75,6 @@ export function useUserDumpFeed(
useEffect(() => {
if (!username) return;
- setState({ status: "loading" });
const controller = new AbortController();
if (cached) {
@@ -126,7 +130,7 @@ export function useUserDumpFeed(
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
- }, [username, endpoint]);
+ }, [username, endpoint, cached, token]);
const { onItemsAppended } = options ?? {};
diff --git a/src/i18n.ts b/src/i18n.ts
new file mode 100644
index 0000000..37d255a
--- /dev/null
+++ b/src/i18n.ts
@@ -0,0 +1,20 @@
+import { i18n } from "@lingui/core";
+
+const SUPPORTED = ["en", "fr"] as const;
+type Locale = (typeof SUPPORTED)[number];
+
+function detectLocale(): Locale {
+ const stored = localStorage.getItem("locale");
+ if (stored && (SUPPORTED as readonly string[]).includes(stored)) return stored as Locale;
+ return navigator.language.startsWith("fr") ? "fr" : "en";
+}
+
+export async function loadCatalog(locale: Locale = detectLocale()) {
+ const { messages } = await import(`./locales/${locale}.po`);
+ i18n.load(locale, messages);
+ i18n.activate(locale);
+ localStorage.setItem("locale", locale);
+}
+
+export { i18n };
+export type { Locale };
diff --git a/src/locales/en.js b/src/locales/en.js
new file mode 100644
index 0000000..c704b98
--- /dev/null
+++ b/src/locales/en.js
@@ -0,0 +1 @@
+/*eslint-disable*/export const 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\"],\"0> followed your playlist <1>\",[\"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\"],\"0> upvoted <1>\",[\"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 files0>\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\"0> started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"SBTElJ\":[\"Searchingā¦\"],\"Sxm8rQ\":[\"Users\"],\"T9bjWt\":[\"<0>\",[\"0\"],\"0> was added to <1>\",[\"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\"],\"0> posted <1>\",[\"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\"],\"0> mentioned you in <1>\",[\"where\"],\"1>\"],\"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 in0>\"],\"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
diff --git a/src/locales/en.mjs b/src/locales/en.mjs
new file mode 100644
index 0000000..86d7f11
--- /dev/null
+++ b/src/locales/en.mjs
@@ -0,0 +1 @@
+/*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\"],\"0> followed your playlist <1>\",[\"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\"],\"0> upvoted <1>\",[\"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 files0>\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\"0> started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"SBTElJ\":[\"Searchingā¦\"],\"Sxm8rQ\":[\"Users\"],\"T9bjWt\":[\"<0>\",[\"0\"],\"0> was added to <1>\",[\"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\"],\"0> posted <1>\",[\"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\"],\"0> mentioned you in <1>\",[\"where\"],\"1>\"],\"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 in0>\"],\"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
diff --git a/src/locales/en.po b/src/locales/en.po
new file mode 100644
index 0000000..fcd7021
--- /dev/null
+++ b/src/locales/en.po
@@ -0,0 +1,956 @@
+msgid ""
+msgstr ""
+"POT-Creation-Date: 2026-03-31 06:22+0000\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: @lingui/cli\n"
+"Language: en\n"
+"Project-Id-Version: \n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Plural-Forms: \n"
+
+#: src/components/CommentThread.tsx:170
+msgid "[deleted]"
+msgstr "[deleted]"
+
+#. placeholder {0}: dump.commentCount
+#: src/components/DumpCard.tsx:82
+msgid "{0, plural, one {# comment} other {# comments}}"
+msgstr "{0, plural, one {# comment} other {# comments}}"
+
+#. placeholder {0}: playlist.dumpCount
+#: src/components/PlaylistCard.tsx:84
+msgid "{0, plural, one {# dump} other {# dumps}}"
+msgstr "{0, plural, one {# dump} other {# dumps}}"
+
+#. placeholder {0}: VALIDATION.USERNAME_MIN
+#. placeholder {1}: VALIDATION.USERNAME_MAX
+#: src/pages/UserRegister.tsx:128
+msgid "{0}ā{1} characters: letters, numbers, or underscores"
+msgstr "{0}ā{1} characters: letters, numbers, or underscores"
+
+#: src/pages/Notifications.tsx:184
+msgid "{days}d ago"
+msgstr "{days}d ago"
+
+#: src/pages/Notifications.tsx:182
+msgid "{hrs}h ago"
+msgstr "{hrs}h ago"
+
+#: src/pages/Search.tsx:176
+msgid "{label} ({count})"
+msgstr "{label} ({count})"
+
+#: src/pages/Notifications.tsx:180
+msgid "{mins}m ago"
+msgstr "{mins}m ago"
+
+#: src/components/CommentThread.tsx:436
+msgid "{visibleCount, plural, one {# comment} other {# comments}}"
+msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
+
+#: src/pages/PlaylistDetail.tsx:605
+#: src/pages/UserPublicProfile.tsx:606
+msgid "ā Back"
+msgstr "ā Back"
+
+#: src/pages/Dump.tsx:216
+#: src/pages/Dump.tsx:318
+#: src/pages/DumpEdit.tsx:166
+msgid "ā Back to all dumps"
+msgstr "ā Back to all dumps"
+
+#: src/pages/UserDumps.tsx:61
+#: src/pages/UserPlaylists.tsx:352
+#: src/pages/UserUpvoted.tsx:130
+msgid "ā Back to profile"
+msgstr "ā Back to profile"
+
+#: src/pages/UserPublicProfile.tsx:90
+msgid "+ Invite someone"
+msgstr "+ Invite someone"
+
+#: src/components/AppHeader.tsx:63
+msgid "+ New"
+msgstr "+ New"
+
+#: src/pages/UserDumps.tsx:82
+#: src/pages/UserPublicProfile.tsx:922
+msgid "+ New dump"
+msgstr "+ New dump"
+
+#: src/components/PlaylistMembershipPanel.tsx:72
+msgid "+ New playlist"
+msgstr "+ New playlist"
+
+#: src/pages/Dump.tsx:248
+msgid "+ Playlist"
+msgstr "+ Playlist"
+
+#. placeholder {0}: d.followerUsername
+#. placeholder {1}: d.playlistTitle
+#: src/pages/Notifications.tsx:124
+msgid "<0>{0}0> followed your playlist <1>{1}1>"
+msgstr "<0>{0}0> followed your playlist <1>{1}1>"
+
+#. placeholder {0}: d.mentionerUsername
+#: src/pages/Notifications.tsx:166
+msgid "<0>{0}0> mentioned you in <1>{where}1>"
+msgstr "<0>{0}0> mentioned you in <1>{where}1>"
+
+#. placeholder {0}: d.dumperUsername
+#. placeholder {1}: d.dumpTitle
+#: src/pages/Notifications.tsx:134
+msgid "<0>{0}0> posted <1>{1}1>"
+msgstr "<0>{0}0> posted <1>{1}1>"
+
+#. placeholder {0}: d.followerUsername
+#: src/pages/Notifications.tsx:115
+msgid "<0>{0}0> started following you"
+msgstr "<0>{0}0> started following you"
+
+#. placeholder {0}: d.voterUsername
+#. placeholder {1}: d.dumpTitle
+#: src/pages/Notifications.tsx:154
+msgid "<0>{0}0> upvoted <1>{1}1>"
+msgstr "<0>{0}0> upvoted <1>{1}1>"
+
+#. placeholder {0}: d.dumpTitle
+#. placeholder {1}: d.playlistTitle
+#: src/pages/Notifications.tsx:144
+msgid "<0>{0}0> was added to <1>{1}1>"
+msgstr "<0>{0}0> was added to <1>{1}1>"
+
+#: src/pages/Notifications.tsx:164
+msgid "a comment"
+msgstr "a comment"
+
+#: src/pages/Notifications.tsx:164
+msgid "a post"
+msgstr "a post"
+
+#: src/pages/UserPublicProfile.tsx:802
+msgid "Add a bioā¦"
+msgstr "Add a bioā¦"
+
+#: src/components/CommentThread.tsx:456
+msgid "Add a commentā¦"
+msgstr "Add a commentā¦"
+
+#: src/pages/UserPublicProfile.tsx:718
+msgid "Add emailā¦"
+msgstr "Add emailā¦"
+
+#: src/components/AddToPlaylistModal.tsx:64
+#: src/components/DumpCreateModal.tsx:262
+msgid "Add to playlist"
+msgstr "Add to playlist"
+
+#: api/auth:
+#~ msgid "Admin access required"
+#~ msgstr "Admin access required"
+
+#. placeholder {0}: dumps.length
+#: src/pages/UserDumps.tsx:114
+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
+msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
+msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
+
+#: src/pages/UserRegister.tsx:160
+msgid "Already have an account? <0>Log in0>"
+msgstr "Already have an account? <0>Log in0>"
+
+#: src/contexts/WSProvider.tsx:168
+#: src/contexts/WSProvider.tsx:360
+msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
+msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
+
+#: src/components/CommentThread.tsx:268
+#: src/components/CommentThread.tsx:353
+#: src/components/CommentThread.tsx:483
+#: src/components/ConfirmModal.tsx:32
+#: src/components/DumpCreateModal.tsx:394
+#: src/components/PlaylistCreateForm.tsx:105
+#: src/pages/DumpEdit.tsx:288
+#: src/pages/PlaylistDetail.tsx:672
+#: src/pages/UserPublicProfile.tsx:700
+#: src/pages/UserPublicProfile.tsx:773
+msgid "Cancel"
+msgstr "Cancel"
+
+#: src/pages/PlaylistDetail.tsx:848
+msgid "Cancel removal"
+msgstr "Cancel removal"
+
+#: api/comments:
+#~ msgid "Cannot edit a deleted comment"
+#~ msgstr "Cannot edit a deleted comment"
+
+#: src/pages/UserPublicProfile.tsx:633
+msgid "Change avatar"
+msgstr "Change avatar"
+
+#: src/pages/UserRegister.tsx:94
+msgid "Checking inviteā¦"
+msgstr "Checking inviteā¦"
+
+#: src/components/Modal.tsx:45
+msgid "Close"
+msgstr "Close"
+
+#: api/comments:
+#~ msgid "Comment not found"
+#~ msgstr "Comment not found"
+
+#: src/pages/UserPublicProfile.tsx:81
+msgid "Copied!"
+msgstr "Copied!"
+
+#: src/pages/UserPublicProfile.tsx:81
+msgid "Copy"
+msgstr "Copy"
+
+#: src/components/CommentThread.tsx:108
+#: src/components/CommentThread.tsx:147
+#: src/components/CommentThread.tsx:425
+msgid "Could not reach the server. Please try again."
+msgstr "Could not reach the server. Please try again."
+
+#: src/components/PlaylistCreateForm.tsx:116
+msgid "Create"
+msgstr "Create"
+
+#: src/components/PlaylistCreateForm.tsx:115
+msgid "Create & Add"
+msgstr "Create & Add"
+
+#. placeholder {0}: created.items.length
+#. placeholder {1}: created.hasMore ? "+" : ""
+#: src/pages/UserPlaylists.tsx:386
+msgid "Created ({0}{1})"
+msgstr "Created ({0}{1})"
+
+#: src/components/PlaylistCreateForm.tsx:113
+msgid "Creatingā¦"
+msgstr "Creatingā¦"
+
+#: src/components/CommentThread.tsx:306
+#: src/components/CommentThread.tsx:312
+#: src/components/ConfirmModal.tsx:16
+#: src/pages/PlaylistDetail.tsx:679
+msgid "Delete"
+msgstr "Delete"
+
+#: src/pages/DumpEdit.tsx:284
+#: src/pages/DumpEdit.tsx:300
+msgid "Delete dump"
+msgstr "Delete dump"
+
+#: src/components/PlaylistCard.tsx:107
+#: src/pages/PlaylistDetail.tsx:861
+#: src/pages/UserPlaylists.tsx:443
+msgid "Delete playlist"
+msgstr "Delete playlist"
+
+#: src/components/CommentThread.tsx:311
+msgid "Delete this comment?"
+msgstr "Delete this comment?"
+
+#: src/pages/DumpEdit.tsx:299
+msgid "Delete this dump? This cannot be undone."
+msgstr "Delete this dump? This cannot be undone."
+
+#: src/pages/PlaylistDetail.tsx:860
+#: src/pages/UserPlaylists.tsx:442
+msgid "Delete this playlist? This cannot be undone."
+msgstr "Delete this playlist? This cannot be undone."
+
+#: src/components/PlaylistCreateForm.tsx:76
+#: src/pages/PlaylistDetail.tsx:710
+msgid "Description (optional)"
+msgstr "Description (optional)"
+
+#: src/components/DumpCreateModal.tsx:439
+msgid "Done"
+msgstr "Done"
+
+#: src/components/FileDropZone.tsx:32
+msgid "Drop a file here"
+msgstr "Drop a file here"
+
+#: src/pages/DumpEdit.tsx:242
+msgid "Drop a replacement here"
+msgstr "Drop a replacement here"
+
+#: src/components/DumpCreateModal.tsx:405
+msgid "Dump it"
+msgstr "Dump it"
+
+#: api/dumps:
+#~ msgid "Dump not found"
+#~ msgstr "Dump not found"
+
+#: src/components/DumpCreateModal.tsx:416
+msgid "Dumped!"
+msgstr "Dumped!"
+
+#: src/pages/Search.tsx:172
+#: src/pages/UserDumps.tsx:75
+msgid "Dumps"
+msgstr "Dumps"
+
+#. placeholder {0}: dumps.items.length
+#. placeholder {1}: dumps.hasMore ? "+" : ""
+#: src/pages/UserPublicProfile.tsx:817
+msgid "Dumps ({0}{1})"
+msgstr "Dumps ({0}{1})"
+
+#: src/pages/Notifications.tsx:341
+msgid "Earlier"
+msgstr "Earlier"
+
+#: src/components/CommentThread.tsx:297
+#: src/pages/Dump.tsx:315
+#: src/pages/PlaylistDetail.tsx:698
+msgid "Edit"
+msgstr "Edit"
+
+#. placeholder {0}: relativeTime(comment.updatedAt)
+#. placeholder {0}: relativeTime(dump.updatedAt)
+#. placeholder {0}: relativeTime(playlist.updatedAt)
+#: src/components/CommentThread.tsx:231
+#: src/pages/Dump.tsx:276
+#: src/pages/PlaylistDetail.tsx:768
+msgid "edited {0}"
+msgstr "edited {0}"
+
+#. placeholder {0}: comment.updatedAt.toLocaleString()
+#. placeholder {0}: dump.updatedAt.toLocaleString()
+#. placeholder {0}: playlist.updatedAt.toLocaleString()
+#: src/components/CommentThread.tsx:229
+#: src/pages/Dump.tsx:274
+#: src/pages/PlaylistDetail.tsx:765
+msgid "Edited {0}"
+msgstr "Edited {0}"
+
+#: src/pages/DumpEdit.tsx:180
+msgid "Editing"
+msgstr "Editing"
+
+#: src/pages/UserRegister.tsx:135
+msgid "Email address"
+msgstr "Email address"
+
+#: src/pages/Search.tsx:206
+msgid "Enter a query to search."
+msgstr "Enter a query to search."
+
+#: src/components/PlaylistCreateForm.tsx:59
+#: src/components/PlaylistCreateForm.tsx:97
+msgid "Failed to create playlist"
+msgstr "Failed to create playlist"
+
+#: src/pages/UserPublicProfile.tsx:62
+#: src/pages/UserPublicProfile.tsx:65
+#: src/pages/UserPublicProfile.tsx:92
+msgid "Failed to generate invite"
+msgstr "Failed to generate invite"
+
+#: src/pages/index/FollowedFeed.tsx:77
+#: src/pages/index/HotFeed.tsx:30
+#: src/pages/index/JournalFeed.tsx:42
+#: src/pages/index/NewFeed.tsx:30
+#: src/pages/Notifications.tsx:321
+msgid "Failed to load"
+msgstr "Failed to load"
+
+#: src/components/DumpCreateModal.tsx:300
+msgid "Failed to post"
+msgstr "Failed to post"
+
+#: src/components/CommentThread.tsx:462
+msgid "Failed to post comment"
+msgstr "Failed to post comment"
+
+#: src/components/CommentThread.tsx:334
+msgid "Failed to post reply"
+msgstr "Failed to post reply"
+
+#: src/pages/PlaylistDetail.tsx:776
+#: src/pages/UserPublicProfile.tsx:546
+#: src/pages/UserPublicProfile.tsx:581
+#: src/pages/UserPublicProfile.tsx:704
+#: src/pages/UserPublicProfile.tsx:776
+msgid "Failed to save"
+msgstr "Failed to save"
+
+#: src/components/CommentThread.tsx:249
+msgid "Failed to save edit"
+msgstr "Failed to save edit"
+
+#: src/pages/UserPublicProfile.tsx:726
+msgid "Failed to update avatar"
+msgstr "Failed to update avatar"
+
+#: src/components/DumpCreateModal.tsx:333
+msgid "Fetching previewā¦"
+msgstr "Fetching previewā¦"
+
+#: src/components/DumpCreateModal.tsx:403
+msgid "Fetchingā¦"
+msgstr "Fetchingā¦"
+
+#: src/components/DumpCreateModal.tsx:293
+#: src/components/FileDropZone.tsx:31
+msgid "File"
+msgstr "File"
+
+#: api/avatars:
+#~ msgid "File content is not a recognised image (JPEG, PNG, GIF, WebP)"
+#~ msgstr "File content is not a recognised image (JPEG, PNG, GIF, WebP)"
+
+#: api/avatars:
+#~ msgid "File too large (max 5 MB)"
+#~ msgstr "File too large (max 5 MB)"
+
+#: api/dumps:
+#~ msgid "File too large (max 50 MB)"
+#~ msgstr "File too large (max 50 MB)"
+
+#: src/components/DumpCreateModal.tsx:187
+msgid "File too large (max 50 MB)."
+msgstr "File too large (max 50 MB)."
+
+#: src/components/FollowButton.tsx:37
+#: src/components/FollowButton.tsx:64
+msgid "Follow"
+msgstr "Follow"
+
+#: src/components/FollowButton.tsx:35
+msgid "Follow {targetUsername}"
+msgstr "Follow {targetUsername}"
+
+#: src/components/FollowButton.tsx:62
+msgid "Follow playlist"
+msgstr "Follow playlist"
+
+#: src/pages/index/FollowedFeed.tsx:359
+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
+msgid "Follow some users to see their dumps here."
+msgstr "Follow some users to see their dumps here."
+
+#: src/components/FeedTabBar.tsx:47
+msgid "Followed"
+msgstr "Followed"
+
+#. placeholder {0}: followed.items.length
+#. placeholder {1}: followed.hasMore ? "+" : ""
+#: src/pages/UserPlaylists.tsx:416
+msgid "Followed ({0}{1})"
+msgstr "Followed ({0}{1})"
+
+#: src/components/FollowButton.tsx:37
+#: src/components/FollowButton.tsx:64
+msgid "Following"
+msgstr "Following"
+
+#: api/playlists:
+#~ msgid "Forbidden"
+#~ msgstr "Forbidden"
+
+#: src/pages/index/FollowedFeed.tsx:325
+msgid "From people"
+msgstr "From people"
+
+#: src/pages/index/FollowedFeed.tsx:332
+msgid "From playlists"
+msgstr "From playlists"
+
+#: src/components/FeedTabBar.tsx:25
+msgid "Hot"
+msgstr "Hot"
+
+#: api/auth:
+#~ msgid "Invalid email address"
+#~ msgstr "Invalid email address"
+
+#: src/pages/UserRegister.tsx:104
+msgid "Invalid invite"
+msgstr "Invalid invite"
+
+#: api/invites:
+#~ msgid "Invalid or expired invite"
+#~ msgstr "Invalid or expired invite"
+
+#: api/dumps:
+#~ msgid "Invalid URL"
+#~ msgstr "Invalid URL"
+
+#. Backend error strings (manually maintained)
+#: api/auth:
+#~ msgid "Invalid username or password"
+#~ msgstr "Invalid username or password"
+
+#: api/invites:
+#~ msgid "Invite already used"
+#~ msgstr "Invite already used"
+
+#: src/pages/UserPublicProfile.tsx:651
+msgid "invited by"
+msgstr "invited by"
+
+#: src/components/FeedTabBar.tsx:39
+msgid "Journal"
+msgstr "Journal"
+
+#: src/pages/Notifications.tsx:178
+msgid "just now"
+msgstr "just now"
+
+#: src/contexts/WSProvider.tsx:359
+msgid "Live updates are temporarily disconnected. Trying to reconnectā¦"
+msgstr "Live updates are temporarily disconnected. Trying to reconnectā¦"
+
+#: src/components/AppHeader.tsx:79
+msgid "Live updates unavailable."
+msgstr "Live updates unavailable."
+
+#: src/pages/Notifications.tsx:386
+msgid "Load more"
+msgstr "Load more"
+
+#: src/pages/Dump.tsx:193
+#: src/pages/DumpEdit.tsx:143
+msgid "Loading dumpā¦"
+msgstr "Loading dumpā¦"
+
+#: src/pages/index/FollowedFeed.tsx:103
+#: src/pages/index/HotFeed.tsx:52
+#: src/pages/index/JournalFeed.tsx:65
+#: src/pages/index/NewFeed.tsx:52
+#: src/pages/Search.tsx:239
+#: src/pages/UserDumps.tsx:111
+#: src/pages/UserPlaylists.tsx:409
+#: src/pages/UserPlaylists.tsx:436
+#: src/pages/UserUpvoted.tsx:180
+msgid "Loading moreā¦"
+msgstr "Loading moreā¦"
+
+#: src/pages/PlaylistDetail.tsx:590
+msgid "Loading playlistā¦"
+msgstr "Loading playlistā¦"
+
+#: src/pages/UserPublicProfile.tsx:590
+msgid "Loading profileā¦"
+msgstr "Loading profileā¦"
+
+#: src/components/PlaylistMembershipPanel.tsx:26
+#: src/components/TextEditor.tsx:273
+#: src/pages/index/FollowedFeed.tsx:74
+#: src/pages/index/HotFeed.tsx:29
+#: src/pages/index/JournalFeed.tsx:41
+#: src/pages/index/NewFeed.tsx:29
+#: src/pages/Notifications.tsx:318
+#: src/pages/Notifications.tsx:386
+#: src/pages/UserDumps.tsx:50
+#: src/pages/UserPlaylists.tsx:341
+#: src/pages/UserUpvoted.tsx:119
+msgid "Loadingā¦"
+msgstr "Loadingā¦"
+
+#: src/components/AppHeader.tsx:70
+#: src/pages/UserLogin.tsx:62
+#: src/pages/UserLogin.tsx:91
+msgid "Log in"
+msgstr "Log in"
+
+#: src/pages/UserPublicProfile.tsx:610
+#: src/pages/UserPublicProfile.tsx:738
+msgid "Log out"
+msgstr "Log out"
+
+#: src/pages/UserLogin.tsx:90
+msgid "Logging inā¦"
+msgstr "Logging inā¦"
+
+#: src/pages/UserLogin.tsx:65
+msgid "Login failed"
+msgstr "Login failed"
+
+#: src/components/FileDropZone.tsx:141
+msgid "Max 50 MB"
+msgstr "Max 50 MB"
+
+#: src/pages/Notifications.tsx:312
+msgid "new"
+msgstr "new"
+
+#: src/components/FeedTabBar.tsx:32
+msgid "New"
+msgstr "New"
+
+#: src/components/DumpCreateModal.tsx:262
+msgid "New dump"
+msgstr "New dump"
+
+#: src/pages/PlaylistDetail.tsx:783
+msgid "No dumps in this playlist yet."
+msgstr "No dumps in this playlist yet."
+
+#: src/pages/Search.tsx:220
+msgid "No dumps match \"{q}\"."
+msgstr "No dumps match \"{q}\"."
+
+#: src/pages/index/HotFeed.tsx:32
+#: src/pages/index/JournalFeed.tsx:44
+#: src/pages/index/NewFeed.tsx:32
+msgid "No dumps yet. Be the first!"
+msgstr "No dumps yet. Be the first!"
+
+#: src/components/TextEditor.tsx:274
+msgid "No emoji found."
+msgstr "No emoji found."
+
+#: src/pages/UserPlaylists.tsx:424
+msgid "No followed playlists yet."
+msgstr "No followed playlists yet."
+
+#: src/pages/Search.tsx:273
+msgid "No playlists match \"{q}\"."
+msgstr "No playlists match \"{q}\"."
+
+#: src/components/PlaylistMembershipPanel.tsx:28
+#: src/pages/UserPlaylists.tsx:392
+#: src/pages/UserPublicProfile.tsx:865
+msgid "No playlists yet."
+msgstr "No playlists yet."
+
+#: src/pages/Search.tsx:249
+msgid "No users match \"{q}\"."
+msgstr "No users match \"{q}\"."
+
+#: api/auth:
+#~ msgid "Not authenticated"
+#~ msgstr "Not authenticated"
+
+#: 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
+msgid "Nothing here yet."
+msgstr "Nothing here yet."
+
+#: src/components/NotificationBell.tsx:42
+#: src/pages/Notifications.tsx:308
+msgid "Notifications"
+msgstr "Notifications"
+
+#: src/components/NotificationBell.tsx:41
+msgid "Notifications ({unreadNotificationCount} unread)"
+msgstr "Notifications ({unreadNotificationCount} unread)"
+
+#: src/components/SearchBar.tsx:71
+msgid "Open search"
+msgstr "Open search"
+
+#: src/components/FileDropZone.tsx:139
+msgid "or <0>browse files0>"
+msgstr "or <0>browse files0>"
+
+#: src/pages/UserLogin.tsx:80
+msgid "Password"
+msgstr "Password"
+
+#. placeholder {0}: VALIDATION.PASSWORD_MIN
+#: src/pages/UserRegister.tsx:142
+msgid "Password (min. {0} characters)"
+msgstr "Password (min. {0} characters)"
+
+#: api/auth:
+#~ msgid "Password must be at least 8 characters"
+#~ msgstr "Password must be at least 8 characters"
+
+#: api/auth:
+#~ msgid "Password must be at most 128 characters"
+#~ msgstr "Password must be at most 128 characters"
+
+#: api/playlists:
+#~ msgid "Playlist not found"
+#~ msgstr "Playlist not found"
+
+#: src/components/AppHeader.tsx:46
+#: src/components/UserMenu.tsx:62
+#: src/pages/Search.tsx:175
+#: src/pages/UserPlaylists.tsx:366
+msgid "Playlists"
+msgstr "Playlists"
+
+#. placeholder {0}: playlists.items.length
+#. placeholder {1}: playlists.hasMore ? "+" : ""
+#: src/pages/UserPublicProfile.tsx:845
+msgid "Playlists ({0}{1})"
+msgstr "Playlists ({0}{1})"
+
+#: src/components/DumpCreateModal.tsx:180
+msgid "Please select a file."
+msgstr "Please select a file."
+
+#: src/components/CommentThread.tsx:472
+msgid "Post comment"
+msgstr "Post comment"
+
+#: src/components/CommentThread.tsx:342
+msgid "Post reply"
+msgstr "Post reply"
+
+#: src/components/CommentThread.tsx:342
+#: src/components/CommentThread.tsx:472
+msgid "Postingā¦"
+msgstr "Postingā¦"
+
+#: src/components/DumpCard.tsx:91
+#: src/components/PlaylistCard.tsx:71
+#: src/components/PlaylistMembershipPanel.tsx:47
+#: src/pages/Dump.tsx:282
+#: src/pages/PlaylistDetail.tsx:748
+msgid "private"
+msgstr "private"
+
+#: src/components/DumpCreateModal.tsx:383
+#: src/components/PlaylistCreateForm.tsx:94
+#: src/pages/DumpEdit.tsx:274
+#: src/pages/PlaylistDetail.tsx:737
+msgid "Private"
+msgstr "Private"
+
+#: src/components/PlaylistCard.tsx:71
+#: src/pages/PlaylistDetail.tsx:748
+msgid "public"
+msgstr "public"
+
+#: src/components/DumpCreateModal.tsx:375
+#: src/components/PlaylistCreateForm.tsx:87
+#: src/pages/DumpEdit.tsx:267
+#: src/pages/PlaylistDetail.tsx:730
+msgid "Public"
+msgstr "Public"
+
+#: src/pages/DumpEdit.tsx:206
+msgid "Refresh metadata"
+msgstr "Refresh metadata"
+
+#: src/pages/DumpEdit.tsx:206
+msgid "Refreshingā¦"
+msgstr "Refreshingā¦"
+
+#: src/pages/UserRegister.tsx:115
+#: src/pages/UserRegister.tsx:155
+msgid "Register"
+msgstr "Register"
+
+#: src/pages/UserRegister.tsx:154
+msgid "Registeringā¦"
+msgstr "Registeringā¦"
+
+#: src/pages/UserRegister.tsx:118
+msgid "Registration failed"
+msgstr "Registration failed"
+
+#: src/components/FileDropZone.tsx:115
+msgid "Remove file"
+msgstr "Remove file"
+
+#: src/pages/PlaylistDetail.tsx:838
+msgid "Remove from playlist"
+msgstr "Remove from playlist"
+
+#: src/pages/DumpEdit.tsx:241
+msgid "Replace file"
+msgstr "Replace file"
+
+#: src/components/CommentThread.tsx:284
+msgid "Reply"
+msgstr "Reply"
+
+#: src/pages/Dump.tsx:209
+#: src/pages/DumpEdit.tsx:159
+msgid "Retry"
+msgstr "Retry"
+
+#: src/components/CommentThread.tsx:257
+#: src/pages/DumpEdit.tsx:291
+#: src/pages/PlaylistDetail.tsx:665
+#: src/pages/UserPublicProfile.tsx:692
+#: src/pages/UserPublicProfile.tsx:765
+msgid "Save"
+msgstr "Save"
+
+#: src/components/CommentThread.tsx:257
+#: src/pages/PlaylistDetail.tsx:665
+#: src/pages/UserPublicProfile.tsx:692
+#: src/pages/UserPublicProfile.tsx:765
+msgid "Savingā¦"
+msgstr "Savingā¦"
+
+#: src/components/SearchBar.tsx:65
+msgid "Search"
+msgstr "Search"
+
+#: src/components/SearchBar.tsx:61
+msgid "Search dumps, users, playlistsā¦"
+msgstr "Search dumps, users, playlistsā¦"
+
+#: src/pages/Search.tsx:214
+msgid "Search failed"
+msgstr "Search failed"
+
+#: src/pages/Search.tsx:210
+msgid "Searchingā¦"
+msgstr "Searchingā¦"
+
+#: src/components/AppHeader.tsx:61
+msgid "Server unreachable"
+msgstr "Server unreachable"
+
+#: src/components/PageError.tsx:13
+msgid "Something went wrong"
+msgstr "Something went wrong"
+
+#: src/components/SearchBar.tsx:71
+msgid "Submit search"
+msgstr "Submit search"
+
+#: src/pages/UserPublicProfile.tsx:755
+msgid "Tell people about yourselfā¦"
+msgstr "Tell people about yourselfā¦"
+
+#: src/components/DumpCreateModal.tsx:363
+#: 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..."
+
+#: src/pages/UserRegister.tsx:105
+msgid "This invite link is missing, expired, or already used."
+msgstr "This invite link is missing, expired, or already used."
+
+#: src/pages/UserLogin.tsx:96
+msgid "This is a mirage."
+msgstr "This is a mirage."
+
+#: src/components/PlaylistCreateForm.tsx:69
+msgid "Title"
+msgstr "Title"
+
+#: src/pages/Notifications.tsx:341
+msgid "Today"
+msgstr "Today"
+
+#: src/pages/PlaylistDetail.tsx:850
+msgid "Undo"
+msgstr "Undo"
+
+#: api/generic:
+#~ msgid "Unexpected server error"
+#~ msgstr "Unexpected server error"
+
+#: src/components/FollowButton.tsx:34
+msgid "Unfollow {targetUsername}"
+msgstr "Unfollow {targetUsername}"
+
+#: src/components/FollowButton.tsx:62
+msgid "Unfollow playlist"
+msgstr "Unfollow playlist"
+
+#: src/pages/UserPublicProfile.tsx:515
+msgid "Upload failed"
+msgstr "Upload failed"
+
+#: src/components/DumpCreateModal.tsx:404
+msgid "Uploadingā¦"
+msgstr "Uploadingā¦"
+
+#: src/pages/UserUpvoted.tsx:147
+msgid "Upvoted"
+msgstr "Upvoted"
+
+#. placeholder {0}: votes.items.length
+#. placeholder {1}: votes.hasMore ? "+" : ""
+#: src/pages/UserPublicProfile.tsx:829
+msgid "Upvoted ({0}{1})"
+msgstr "Upvoted ({0}{1})"
+
+#: src/components/DumpCreateModal.tsx:309
+#: src/pages/DumpEdit.tsx:221
+msgid "URL"
+msgstr "URL"
+
+#: src/components/DumpCreateModal.tsx:164
+msgid "URL is required."
+msgstr "URL is required."
+
+#: src/components/UserMenu.tsx:37
+msgid "User menu"
+msgstr "User menu"
+
+#: src/pages/UserLogin.tsx:72
+#: src/pages/UserRegister.tsx:125
+msgid "Username"
+msgstr "Username"
+
+#: api/auth:
+#~ msgid "Username already exists"
+#~ msgstr "Username already exists"
+
+#: api/auth:
+#~ msgid "Username must be 1ā32 characters and contain only letters, numbers, or underscores"
+#~ msgstr "Username must be 1ā32 characters and contain only letters, numbers, or underscores"
+
+#: src/pages/Search.tsx:174
+msgid "Users"
+msgstr "Users"
+
+#: src/pages/UserPublicProfile.tsx:878
+#: src/pages/UserPublicProfile.tsx:948
+#: src/pages/UserPublicProfile.tsx:1076
+msgid "View all ā"
+msgstr "View all ā"
+
+#: src/components/DumpCreateModal.tsx:418
+msgid "View dump ā"
+msgstr "View dump ā"
+
+#: src/components/DumpCreateModal.tsx:356
+#: src/pages/DumpEdit.tsx:250
+msgid "Why are you dumping this?"
+msgstr "Why are you dumping this?"
+
+#: src/components/CommentThread.tsx:329
+msgid "Write a replyā¦"
+msgstr "Write a replyā¦"
+
+#: src/pages/Notifications.tsx:341
+msgid "Yesterday"
+msgstr "Yesterday"
+
+#: src/pages/Notifications.tsx:329
+msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
+msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
+
+#: src/pages/index/HotFeed.tsx:54
+#: src/pages/index/JournalFeed.tsx:67
+#: src/pages/index/NewFeed.tsx:54
+#: src/pages/Search.tsx:242
+msgid "You've reached the end."
+msgstr "You've reached the end."
diff --git a/src/locales/fr.po b/src/locales/fr.po
new file mode 100644
index 0000000..c9f47e9
--- /dev/null
+++ b/src/locales/fr.po
@@ -0,0 +1,870 @@
+msgid ""
+msgstr ""
+"POT-Creation-Date: 2026-04-01 16:55+0000\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: @lingui/cli\n"
+"Language: fr\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: src/components/CommentThread.tsx:170
+msgid "[deleted]"
+msgstr "[supprimƩ]"
+
+#. placeholder {0}: dump.commentCount
+#: src/components/DumpCard.tsx:82
+msgid "{0, plural, one {# comment} other {# comments}}"
+msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
+
+#. placeholder {0}: playlist.dumpCount
+#: src/components/PlaylistCard.tsx:84
+msgid "{0, plural, one {# dump} other {# dumps}}"
+msgstr "{0, plural, one {# reco} other {# recos}}"
+
+#. placeholder {0}: VALIDATION.USERNAME_MIN
+#. placeholder {1}: VALIDATION.USERNAME_MAX
+#: src/pages/UserRegister.tsx:128
+msgid "{0}ā{1} characters: letters, numbers, or underscores"
+msgstr "{0}ā{1} caractĆØres : lettres, chiffres ou tirets bas"
+
+#: src/pages/Notifications.tsx:184
+msgid "{days}d ago"
+msgstr "il y a {days}j"
+
+#: src/pages/Notifications.tsx:182
+msgid "{hrs}h ago"
+msgstr "il y a {hrs}h"
+
+#: src/pages/Search.tsx:176
+msgid "{label} ({count})"
+msgstr "{label} ({count})"
+
+#: src/pages/Notifications.tsx:180
+msgid "{mins}m ago"
+msgstr "il y a {mins}min"
+
+#: src/components/CommentThread.tsx:436
+msgid "{visibleCount, plural, one {# comment} other {# comments}}"
+msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
+
+#: src/pages/PlaylistDetail.tsx:605
+#: src/pages/UserPublicProfile.tsx:606
+msgid "ā Back"
+msgstr "ā Retour"
+
+#: src/pages/Dump.tsx:216
+#: src/pages/Dump.tsx:318
+#: src/pages/DumpEdit.tsx:166
+msgid "ā Back to all dumps"
+msgstr "ā Retour Ć toutes les recos"
+
+#: src/pages/UserDumps.tsx:61
+#: src/pages/UserPlaylists.tsx:352
+#: src/pages/UserUpvoted.tsx:130
+msgid "ā Back to profile"
+msgstr "ā Retour au profil"
+
+#: src/pages/UserPublicProfile.tsx:90
+msgid "+ Invite someone"
+msgstr "+ Inviter quelqu'un"
+
+#: src/components/AppHeader.tsx:63
+msgid "+ New"
+msgstr "+ Nouveau"
+
+#: src/pages/UserDumps.tsx:82
+#: src/pages/UserPublicProfile.tsx:922
+msgid "+ New dump"
+msgstr "+ Nouvelle reco"
+
+#: src/components/PlaylistMembershipPanel.tsx:72
+msgid "+ New playlist"
+msgstr "+ Nouvelle collection"
+
+#: src/pages/Dump.tsx:248
+msgid "+ Playlist"
+msgstr "+ Collection"
+
+#. placeholder {0}: d.followerUsername
+#. placeholder {1}: d.playlistTitle
+#: src/pages/Notifications.tsx:124
+msgid "<0>{0}0> followed your playlist <1>{1}1>"
+msgstr "<0>{0}0> a suivi votre collection <1>{1}1>"
+
+#. placeholder {0}: d.mentionerUsername
+#: src/pages/Notifications.tsx:166
+msgid "<0>{0}0> mentioned you in <1>{where}1>"
+msgstr "<0>{0}0> vous a mentionnƩ dans <1>{where}1>"
+
+#. placeholder {0}: d.dumperUsername
+#. placeholder {1}: d.dumpTitle
+#: src/pages/Notifications.tsx:134
+msgid "<0>{0}0> posted <1>{1}1>"
+msgstr "<0>{0}0> a publiƩ <1>{1}1>"
+
+#. placeholder {0}: d.followerUsername
+#: src/pages/Notifications.tsx:115
+msgid "<0>{0}0> started following you"
+msgstr "<0>{0}0> a commencé à vous suivre"
+
+#. placeholder {0}: d.voterUsername
+#. placeholder {1}: d.dumpTitle
+#: src/pages/Notifications.tsx:154
+msgid "<0>{0}0> upvoted <1>{1}1>"
+msgstr "<0>{0}0> a votƩ pour <1>{1}1>"
+
+#. placeholder {0}: d.dumpTitle
+#. placeholder {1}: d.playlistTitle
+#: src/pages/Notifications.tsx:144
+msgid "<0>{0}0> was added to <1>{1}1>"
+msgstr "<0>{0}0> a été ajouté à <1>{1}1>"
+
+#: src/pages/Notifications.tsx:164
+msgid "a comment"
+msgstr "un commentaire"
+
+#: src/pages/Notifications.tsx:164
+msgid "a post"
+msgstr "une publication"
+
+#: src/pages/UserPublicProfile.tsx:802
+msgid "Add a bioā¦"
+msgstr "Ajouter une bioā¦"
+
+#: src/components/CommentThread.tsx:456
+msgid "Add a commentā¦"
+msgstr "Ajouter un commentaireā¦"
+
+#: src/pages/UserPublicProfile.tsx:718
+msgid "Add emailā¦"
+msgstr "Ajouter un e-mailā¦"
+
+#: src/components/AddToPlaylistModal.tsx:64
+#: src/components/DumpCreateModal.tsx:262
+msgid "Add to playlist"
+msgstr "Ajouter Ć la collection"
+
+#. placeholder {0}: dumps.length
+#: src/pages/UserDumps.tsx:114
+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
+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."
+
+#: src/pages/UserRegister.tsx:160
+msgid "Already have an account? <0>Log in0>"
+msgstr "Vous avez déjà un compte ? <0>Se connecter0>"
+
+#: src/contexts/WSProvider.tsx:168
+#: src/contexts/WSProvider.tsx:360
+msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
+msgstr "Impossible de se connecter au serveur de mises Ć jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
+
+#: src/components/CommentThread.tsx:268
+#: src/components/CommentThread.tsx:353
+#: src/components/CommentThread.tsx:483
+#: src/components/ConfirmModal.tsx:32
+#: src/components/DumpCreateModal.tsx:394
+#: src/components/PlaylistCreateForm.tsx:105
+#: src/pages/DumpEdit.tsx:288
+#: src/pages/PlaylistDetail.tsx:672
+#: src/pages/UserPublicProfile.tsx:700
+#: src/pages/UserPublicProfile.tsx:773
+msgid "Cancel"
+msgstr "Annuler"
+
+#: src/pages/PlaylistDetail.tsx:848
+msgid "Cancel removal"
+msgstr "Annuler la suppression"
+
+#: src/pages/UserPublicProfile.tsx:633
+msgid "Change avatar"
+msgstr "Changer l'avatar"
+
+#: src/pages/UserRegister.tsx:94
+msgid "Checking inviteā¦"
+msgstr "VĆ©rification de l'invitationā¦"
+
+#: src/components/Modal.tsx:45
+msgid "Close"
+msgstr "Fermer"
+
+#: src/pages/UserPublicProfile.tsx:81
+msgid "Copied!"
+msgstr "CopiƩ !"
+
+#: src/pages/UserPublicProfile.tsx:81
+msgid "Copy"
+msgstr "Copier"
+
+#: src/components/CommentThread.tsx:108
+#: src/components/CommentThread.tsx:147
+#: src/components/CommentThread.tsx:425
+msgid "Could not reach the server. Please try again."
+msgstr "Impossible de contacter le serveur. Veuillez rƩessayer."
+
+#: src/components/PlaylistCreateForm.tsx:116
+msgid "Create"
+msgstr "CrƩer"
+
+#: src/components/PlaylistCreateForm.tsx:115
+msgid "Create & Add"
+msgstr "CrƩer et ajouter"
+
+#. placeholder {0}: created.items.length
+#. placeholder {1}: created.hasMore ? "+" : ""
+#: src/pages/UserPlaylists.tsx:386
+msgid "Created ({0}{1})"
+msgstr "CrƩƩes ({0}{1})"
+
+#: src/components/PlaylistCreateForm.tsx:113
+msgid "Creatingā¦"
+msgstr "CrĆ©ationā¦"
+
+#: src/components/CommentThread.tsx:306
+#: src/components/CommentThread.tsx:312
+#: src/components/ConfirmModal.tsx:16
+#: src/pages/PlaylistDetail.tsx:679
+msgid "Delete"
+msgstr "Supprimer"
+
+#: src/pages/DumpEdit.tsx:284
+#: src/pages/DumpEdit.tsx:300
+msgid "Delete dump"
+msgstr "Supprimer la reco"
+
+#: src/components/PlaylistCard.tsx:107
+#: src/pages/PlaylistDetail.tsx:861
+#: src/pages/UserPlaylists.tsx:443
+msgid "Delete playlist"
+msgstr "Supprimer la collection"
+
+#: src/components/CommentThread.tsx:311
+msgid "Delete this comment?"
+msgstr "Supprimer ce commentaire ?"
+
+#: src/pages/DumpEdit.tsx:299
+msgid "Delete this dump? This cannot be undone."
+msgstr "Supprimer cette reco ? Cette action est irrƩversible."
+
+#: src/pages/PlaylistDetail.tsx:860
+#: src/pages/UserPlaylists.tsx:442
+msgid "Delete this playlist? This cannot be undone."
+msgstr "Supprimer cette collection ? Cette action est irrƩversible."
+
+#: src/components/PlaylistCreateForm.tsx:76
+#: src/pages/PlaylistDetail.tsx:710
+msgid "Description (optional)"
+msgstr "Description (facultatif)"
+
+#: src/components/DumpCreateModal.tsx:439
+msgid "Done"
+msgstr "TerminƩ"
+
+#: src/components/FileDropZone.tsx:32
+msgid "Drop a file here"
+msgstr "DƩposez un fichier ici"
+
+#: src/pages/DumpEdit.tsx:242
+msgid "Drop a replacement here"
+msgstr "DƩposez un fichier de remplacement ici"
+
+#: src/components/DumpCreateModal.tsx:405
+msgid "Dump it"
+msgstr "Recommander"
+
+#: src/components/DumpCreateModal.tsx:416
+msgid "Dumped!"
+msgstr "RecommandƩ !"
+
+#: src/pages/Search.tsx:172
+#: src/pages/UserDumps.tsx:75
+msgid "Dumps"
+msgstr "Recos"
+
+#. placeholder {0}: dumps.items.length
+#. placeholder {1}: dumps.hasMore ? "+" : ""
+#: src/pages/UserPublicProfile.tsx:817
+msgid "Dumps ({0}{1})"
+msgstr "Recos ({0}{1})"
+
+#: src/pages/Notifications.tsx:341
+msgid "Earlier"
+msgstr "Plus tƓt"
+
+#: src/components/CommentThread.tsx:297
+#: src/pages/Dump.tsx:315
+#: src/pages/PlaylistDetail.tsx:698
+msgid "Edit"
+msgstr "Modifier"
+
+#. placeholder {0}: relativeTime(comment.updatedAt)
+#. placeholder {0}: relativeTime(dump.updatedAt)
+#. placeholder {0}: relativeTime(playlist.updatedAt)
+#: src/components/CommentThread.tsx:231
+#: src/pages/Dump.tsx:276
+#: src/pages/PlaylistDetail.tsx:768
+msgid "edited {0}"
+msgstr "modifiƩ {0}"
+
+#. placeholder {0}: comment.updatedAt.toLocaleString()
+#. placeholder {0}: dump.updatedAt.toLocaleString()
+#. placeholder {0}: playlist.updatedAt.toLocaleString()
+#: src/components/CommentThread.tsx:229
+#: src/pages/Dump.tsx:274
+#: src/pages/PlaylistDetail.tsx:765
+msgid "Edited {0}"
+msgstr "ModifiƩ le {0}"
+
+#: src/pages/DumpEdit.tsx:180
+msgid "Editing"
+msgstr "Modification"
+
+#: src/pages/UserRegister.tsx:135
+msgid "Email address"
+msgstr "Adresse e-mail"
+
+#: src/pages/Search.tsx:206
+msgid "Enter a query to search."
+msgstr "Saisissez une recherche."
+
+#: src/components/PlaylistCreateForm.tsx:59
+#: src/components/PlaylistCreateForm.tsx:97
+msgid "Failed to create playlist"
+msgstr "Impossible de crƩer la collection"
+
+#: src/pages/UserPublicProfile.tsx:62
+#: src/pages/UserPublicProfile.tsx:65
+#: src/pages/UserPublicProfile.tsx:92
+msgid "Failed to generate invite"
+msgstr "Impossible de gƩnƩrer une invitation"
+
+#: src/pages/index/FollowedFeed.tsx:77
+#: src/pages/index/HotFeed.tsx:30
+#: src/pages/index/JournalFeed.tsx:42
+#: src/pages/index/NewFeed.tsx:30
+#: src/pages/Notifications.tsx:321
+msgid "Failed to load"
+msgstr "Chargement ƩchouƩ"
+
+#: src/components/DumpCreateModal.tsx:300
+msgid "Failed to post"
+msgstr "Publication ƩchouƩe"
+
+#: src/components/CommentThread.tsx:462
+msgid "Failed to post comment"
+msgstr "Impossible de publier le commentaire"
+
+#: src/components/CommentThread.tsx:334
+msgid "Failed to post reply"
+msgstr "Impossible de publier la rƩponse"
+
+#: src/pages/PlaylistDetail.tsx:776
+#: src/pages/UserPublicProfile.tsx:546
+#: src/pages/UserPublicProfile.tsx:581
+#: src/pages/UserPublicProfile.tsx:704
+#: src/pages/UserPublicProfile.tsx:776
+msgid "Failed to save"
+msgstr "Enregistrement ƩchouƩ"
+
+#: src/components/CommentThread.tsx:249
+msgid "Failed to save edit"
+msgstr "Impossible d'enregistrer la modification"
+
+#: src/pages/UserPublicProfile.tsx:726
+msgid "Failed to update avatar"
+msgstr "Impossible de mettre Ć jour l'avatar"
+
+#: src/components/DumpCreateModal.tsx:333
+msgid "Fetching previewā¦"
+msgstr "RĆ©cupĆ©ration de l'aperƧuā¦"
+
+#: src/components/DumpCreateModal.tsx:403
+msgid "Fetchingā¦"
+msgstr "RĆ©cupĆ©rationā¦"
+
+#: src/components/DumpCreateModal.tsx:293
+#: src/components/FileDropZone.tsx:31
+msgid "File"
+msgstr "Fichier"
+
+#: src/components/DumpCreateModal.tsx:187
+msgid "File too large (max 50 MB)."
+msgstr "Fichier trop volumineux (max 50 Mo)."
+
+#: src/components/FollowButton.tsx:37
+#: src/components/FollowButton.tsx:64
+msgid "Follow"
+msgstr "Suivre"
+
+#: src/components/FollowButton.tsx:35
+msgid "Follow {targetUsername}"
+msgstr "Suivre {targetUsername}"
+
+#: src/components/FollowButton.tsx:62
+msgid "Follow playlist"
+msgstr "Suivre la collection"
+
+#: src/pages/index/FollowedFeed.tsx:359
+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
+msgid "Follow some users to see their dumps here."
+msgstr "Suivez des utilisateurs pour voir leurs recos ici."
+
+#: src/components/FeedTabBar.tsx:47
+msgid "Followed"
+msgstr "Suivi"
+
+#. placeholder {0}: followed.items.length
+#. placeholder {1}: followed.hasMore ? "+" : ""
+#: src/pages/UserPlaylists.tsx:416
+msgid "Followed ({0}{1})"
+msgstr "Suivies ({0}{1})"
+
+#: src/components/FollowButton.tsx:37
+#: src/components/FollowButton.tsx:64
+msgid "Following"
+msgstr "AbonnƩ"
+
+#: src/pages/index/FollowedFeed.tsx:325
+msgid "From people"
+msgstr "De personnes"
+
+#: src/pages/index/FollowedFeed.tsx:332
+msgid "From playlists"
+msgstr "De collections"
+
+#: src/components/FeedTabBar.tsx:25
+msgid "Hot"
+msgstr "Tendances"
+
+#: src/pages/UserRegister.tsx:104
+msgid "Invalid invite"
+msgstr "Invitation invalide"
+
+#: src/pages/UserPublicProfile.tsx:651
+msgid "invited by"
+msgstr "invitƩ par"
+
+#: src/components/FeedTabBar.tsx:39
+msgid "Journal"
+msgstr "Journal"
+
+#: src/pages/Notifications.tsx:178
+msgid "just now"
+msgstr "Ć l'instant"
+
+#: src/contexts/WSProvider.tsx:359
+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
+msgid "Live updates unavailable."
+msgstr "Mises Ć jour en direct indisponibles."
+
+#: src/pages/Notifications.tsx:386
+msgid "Load more"
+msgstr "Charger plus"
+
+#: src/pages/Dump.tsx:193
+#: src/pages/DumpEdit.tsx:143
+msgid "Loading dumpā¦"
+msgstr "Chargement de la recoā¦"
+
+#: src/pages/index/FollowedFeed.tsx:103
+#: src/pages/index/HotFeed.tsx:52
+#: src/pages/index/JournalFeed.tsx:65
+#: src/pages/index/NewFeed.tsx:52
+#: src/pages/Search.tsx:239
+#: src/pages/UserDumps.tsx:111
+#: src/pages/UserPlaylists.tsx:409
+#: src/pages/UserPlaylists.tsx:436
+#: src/pages/UserUpvoted.tsx:180
+msgid "Loading moreā¦"
+msgstr "Chargementā¦"
+
+#: src/pages/PlaylistDetail.tsx:590
+msgid "Loading playlistā¦"
+msgstr "Chargement de la collectionā¦"
+
+#: src/pages/UserPublicProfile.tsx:590
+msgid "Loading profileā¦"
+msgstr "Chargement du profilā¦"
+
+#: src/components/PlaylistMembershipPanel.tsx:26
+#: src/components/TextEditor.tsx:273
+#: src/pages/index/FollowedFeed.tsx:74
+#: src/pages/index/HotFeed.tsx:29
+#: src/pages/index/JournalFeed.tsx:41
+#: src/pages/index/NewFeed.tsx:29
+#: src/pages/Notifications.tsx:318
+#: src/pages/Notifications.tsx:386
+#: src/pages/UserDumps.tsx:50
+#: src/pages/UserPlaylists.tsx:341
+#: src/pages/UserUpvoted.tsx:119
+msgid "Loadingā¦"
+msgstr "Chargementā¦"
+
+#: src/components/AppHeader.tsx:70
+#: src/pages/UserLogin.tsx:62
+#: src/pages/UserLogin.tsx:91
+msgid "Log in"
+msgstr "Se connecter"
+
+#: src/pages/UserPublicProfile.tsx:610
+#: src/pages/UserPublicProfile.tsx:738
+msgid "Log out"
+msgstr "Se dƩconnecter"
+
+#: src/pages/UserLogin.tsx:90
+msgid "Logging inā¦"
+msgstr "Connexionā¦"
+
+#: src/pages/UserLogin.tsx:65
+msgid "Login failed"
+msgstr "Connexion ƩchouƩe"
+
+#: src/components/FileDropZone.tsx:141
+msgid "Max 50 MB"
+msgstr "Max 50 Mo"
+
+#: src/pages/Notifications.tsx:312
+msgid "new"
+msgstr "nouveau"
+
+#: src/components/FeedTabBar.tsx:32
+msgid "New"
+msgstr "Nouveau"
+
+#: src/components/DumpCreateModal.tsx:262
+msgid "New dump"
+msgstr "Nouvelle reco"
+
+#: src/pages/PlaylistDetail.tsx:783
+msgid "No dumps in this playlist yet."
+msgstr "Aucune reco dans cette collection pour l'instant."
+
+#: src/pages/Search.tsx:220
+msgid "No dumps match \"{q}\"."
+msgstr "Aucune reco ne correspond Ć Ā« {q} Ā»."
+
+#: src/pages/index/HotFeed.tsx:32
+#: src/pages/index/JournalFeed.tsx:44
+#: src/pages/index/NewFeed.tsx:32
+msgid "No dumps yet. Be the first!"
+msgstr "Pas encore de recos. Soyez le premier !"
+
+#: src/components/TextEditor.tsx:274
+msgid "No emoji found."
+msgstr "Aucun emoji trouvƩ."
+
+#: src/pages/UserPlaylists.tsx:424
+msgid "No followed playlists yet."
+msgstr "Pas encore de collections suivies."
+
+#: src/pages/Search.tsx:273
+msgid "No playlists match \"{q}\"."
+msgstr "Aucune collection ne correspond Ć Ā« {q} Ā»."
+
+#: src/components/PlaylistMembershipPanel.tsx:28
+#: src/pages/UserPlaylists.tsx:392
+#: src/pages/UserPublicProfile.tsx:865
+msgid "No playlists yet."
+msgstr "Pas encore de collections."
+
+#: src/pages/Search.tsx:249
+msgid "No users match \"{q}\"."
+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
+msgid "Nothing here yet."
+msgstr "Rien ici pour l'instant."
+
+#: src/components/NotificationBell.tsx:42
+#: src/pages/Notifications.tsx:308
+msgid "Notifications"
+msgstr "Notifications"
+
+#: src/components/NotificationBell.tsx:41
+msgid "Notifications ({unreadNotificationCount} unread)"
+msgstr "Notifications ({unreadNotificationCount} non lues)"
+
+#: src/components/SearchBar.tsx:71
+msgid "Open search"
+msgstr "Ouvrir la recherche"
+
+#: src/components/FileDropZone.tsx:139
+msgid "or <0>browse files0>"
+msgstr "ou <0>parcourir les fichiers0>"
+
+#: src/pages/UserLogin.tsx:80
+msgid "Password"
+msgstr "Mot de passe"
+
+#. placeholder {0}: VALIDATION.PASSWORD_MIN
+#: src/pages/UserRegister.tsx:142
+msgid "Password (min. {0} characters)"
+msgstr "Mot de passe (min. {0} caractĆØres)"
+
+#: src/components/AppHeader.tsx:46
+#: src/components/UserMenu.tsx:62
+#: src/pages/Search.tsx:175
+#: src/pages/UserPlaylists.tsx:366
+msgid "Playlists"
+msgstr "Collections"
+
+#. placeholder {0}: playlists.items.length
+#. placeholder {1}: playlists.hasMore ? "+" : ""
+#: src/pages/UserPublicProfile.tsx:845
+msgid "Playlists ({0}{1})"
+msgstr "Collections ({0}{1})"
+
+#: src/components/DumpCreateModal.tsx:180
+msgid "Please select a file."
+msgstr "Veuillez sƩlectionner un fichier."
+
+#: src/components/CommentThread.tsx:472
+msgid "Post comment"
+msgstr "Publier le commentaire"
+
+#: src/components/CommentThread.tsx:342
+msgid "Post reply"
+msgstr "Publier la rƩponse"
+
+#: src/components/CommentThread.tsx:342
+#: src/components/CommentThread.tsx:472
+msgid "Postingā¦"
+msgstr "Publicationā¦"
+
+#: src/components/DumpCard.tsx:91
+#: src/components/PlaylistCard.tsx:71
+#: src/components/PlaylistMembershipPanel.tsx:47
+#: src/pages/Dump.tsx:282
+#: src/pages/PlaylistDetail.tsx:748
+msgid "private"
+msgstr "privƩ"
+
+#: src/components/DumpCreateModal.tsx:383
+#: src/components/PlaylistCreateForm.tsx:94
+#: src/pages/DumpEdit.tsx:274
+#: src/pages/PlaylistDetail.tsx:737
+msgid "Private"
+msgstr "PrivƩ"
+
+#: src/components/PlaylistCard.tsx:71
+#: src/pages/PlaylistDetail.tsx:748
+msgid "public"
+msgstr "public"
+
+#: src/components/DumpCreateModal.tsx:375
+#: src/components/PlaylistCreateForm.tsx:87
+#: src/pages/DumpEdit.tsx:267
+#: src/pages/PlaylistDetail.tsx:730
+msgid "Public"
+msgstr "Public"
+
+#: src/pages/DumpEdit.tsx:206
+msgid "Refresh metadata"
+msgstr "Actualiser les mƩtadonnƩes"
+
+#: src/pages/DumpEdit.tsx:206
+msgid "Refreshingā¦"
+msgstr "Actualisationā¦"
+
+#: src/pages/UserRegister.tsx:115
+#: src/pages/UserRegister.tsx:155
+msgid "Register"
+msgstr "S'inscrire"
+
+#: src/pages/UserRegister.tsx:154
+msgid "Registeringā¦"
+msgstr "Inscriptionā¦"
+
+#: src/pages/UserRegister.tsx:118
+msgid "Registration failed"
+msgstr "Inscription ƩchouƩe"
+
+#: src/components/FileDropZone.tsx:115
+msgid "Remove file"
+msgstr "Supprimer le fichier"
+
+#: src/pages/PlaylistDetail.tsx:838
+msgid "Remove from playlist"
+msgstr "Retirer de la collection"
+
+#: src/pages/DumpEdit.tsx:241
+msgid "Replace file"
+msgstr "Remplacer le fichier"
+
+#: src/components/CommentThread.tsx:284
+msgid "Reply"
+msgstr "RƩpondre"
+
+#: src/pages/Dump.tsx:209
+#: src/pages/DumpEdit.tsx:159
+msgid "Retry"
+msgstr "RƩessayer"
+
+#: src/components/CommentThread.tsx:257
+#: src/pages/DumpEdit.tsx:291
+#: src/pages/PlaylistDetail.tsx:665
+#: src/pages/UserPublicProfile.tsx:692
+#: src/pages/UserPublicProfile.tsx:765
+msgid "Save"
+msgstr "Enregistrer"
+
+#: src/components/CommentThread.tsx:257
+#: src/pages/PlaylistDetail.tsx:665
+#: src/pages/UserPublicProfile.tsx:692
+#: src/pages/UserPublicProfile.tsx:765
+msgid "Savingā¦"
+msgstr "Enregistrementā¦"
+
+#: src/components/SearchBar.tsx:65
+msgid "Search"
+msgstr "Rechercher"
+
+#: src/components/SearchBar.tsx:61
+msgid "Search dumps, users, playlistsā¦"
+msgstr "Rechercher des recos, utilisateurs, collectionsā¦"
+
+#: src/pages/Search.tsx:214
+msgid "Search failed"
+msgstr "Recherche ƩchouƩe"
+
+#: src/pages/Search.tsx:210
+msgid "Searchingā¦"
+msgstr "Rechercheā¦"
+
+#: src/components/AppHeader.tsx:61
+msgid "Server unreachable"
+msgstr "Serveur inaccessible"
+
+#: src/components/PageError.tsx:13
+msgid "Something went wrong"
+msgstr "Une erreur est survenue"
+
+#: src/components/SearchBar.tsx:71
+msgid "Submit search"
+msgstr "Lancer la recherche"
+
+#: src/pages/UserPublicProfile.tsx:755
+msgid "Tell people about yourselfā¦"
+msgstr "Parlez de vousā¦"
+
+#: src/components/DumpCreateModal.tsx:363
+#: src/pages/DumpEdit.tsx:256
+msgid "Tell the community what makes this worth their time..."
+msgstr "Dites Ć la communautĆ© pourquoi Ƨa vaut le coupā¦"
+
+#: src/pages/UserRegister.tsx:105
+msgid "This invite link is missing, expired, or already used."
+msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
+
+#: src/pages/UserLogin.tsx:96
+msgid "This is a mirage."
+msgstr "C'est un mirage."
+
+#: src/components/PlaylistCreateForm.tsx:69
+msgid "Title"
+msgstr "Titre"
+
+#: src/pages/Notifications.tsx:341
+msgid "Today"
+msgstr "Aujourd'hui"
+
+#: src/pages/PlaylistDetail.tsx:850
+msgid "Undo"
+msgstr "Annuler"
+
+#: src/components/FollowButton.tsx:34
+msgid "Unfollow {targetUsername}"
+msgstr "Ne plus suivre {targetUsername}"
+
+#: src/components/FollowButton.tsx:62
+msgid "Unfollow playlist"
+msgstr "Ne plus suivre la collection"
+
+#: src/pages/UserPublicProfile.tsx:515
+msgid "Upload failed"
+msgstr "Envoi ƩchouƩ"
+
+#: src/components/DumpCreateModal.tsx:404
+msgid "Uploadingā¦"
+msgstr "Envoiā¦"
+
+#: src/pages/UserUpvoted.tsx:147
+msgid "Upvoted"
+msgstr "VotƩ"
+
+#. placeholder {0}: votes.items.length
+#. placeholder {1}: votes.hasMore ? "+" : ""
+#: src/pages/UserPublicProfile.tsx:829
+msgid "Upvoted ({0}{1})"
+msgstr "VotƩs ({0}{1})"
+
+#: src/components/DumpCreateModal.tsx:309
+#: src/pages/DumpEdit.tsx:221
+msgid "URL"
+msgstr "URL"
+
+#: src/components/DumpCreateModal.tsx:164
+msgid "URL is required."
+msgstr "L'URL est obligatoire."
+
+#: src/components/UserMenu.tsx:37
+msgid "User menu"
+msgstr "Menu utilisateur"
+
+#: src/pages/UserLogin.tsx:72
+#: src/pages/UserRegister.tsx:125
+msgid "Username"
+msgstr "Nom d'utilisateur"
+
+#: src/pages/Search.tsx:174
+msgid "Users"
+msgstr "Utilisateurs"
+
+#: src/pages/UserPublicProfile.tsx:878
+#: src/pages/UserPublicProfile.tsx:948
+#: src/pages/UserPublicProfile.tsx:1076
+msgid "View all ā"
+msgstr "Tout voir ā"
+
+#: src/components/DumpCreateModal.tsx:418
+msgid "View dump ā"
+msgstr "Voir la reco ā"
+
+#: src/components/DumpCreateModal.tsx:356
+#: src/pages/DumpEdit.tsx:250
+msgid "Why are you dumping this?"
+msgstr "Pourquoi recommandez-vous Ƨa ?"
+
+#: src/components/CommentThread.tsx:329
+msgid "Write a replyā¦"
+msgstr "Ćcrire une rĆ©ponseā¦"
+
+#: src/pages/Notifications.tsx:341
+msgid "Yesterday"
+msgstr "Hier"
+
+#: src/pages/Notifications.tsx:329
+msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
+msgstr "Vous serez notifiƩ lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
+
+#: src/pages/index/HotFeed.tsx:54
+#: src/pages/index/JournalFeed.tsx:67
+#: src/pages/index/NewFeed.tsx:54
+#: src/pages/Search.tsx:242
+msgid "You've reached the end."
+msgstr "Vous avez tout lu, tout vu, tout bu."
diff --git a/src/main.tsx b/src/main.tsx
index 4abadfe..342a5fa 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,12 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { I18nProvider } from "@lingui/react";
import App from "./App.tsx";
+import { i18n, loadCatalog } from "./i18n.ts";
import "./index.css";
+await loadCatalog();
+
createRoot(document.getElementById("root")!).render(
-
+
+
+
,
);
diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx
index 32b4caf..a0c59d8 100644
--- a/src/pages/Dump.tsx
+++ b/src/pages/Dump.tsx
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router";
+import { t } from "@lingui/core/macro"
+import { Trans } from "@lingui/react/macro";
import { dumpUrl } from "../utils/urls.ts";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
@@ -105,7 +107,7 @@ export function Dump() {
}
})();
return () => controller.abort();
- }, [selectedDump, preloaded]);
+ }, [selectedDump, preloaded, token]);
useEffect(() => {
if (!lastDumpEvent) return;
@@ -143,16 +145,14 @@ export function Dump() {
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.add("comment-node--highlight");
- const t = setTimeout(
+ const timer = setTimeout(
() => el.classList.remove("comment-node--highlight"),
2000,
);
- return () => clearTimeout(t);
+ return () => clearTimeout(timer);
}, [comments, location.hash]);
// React to WS comment events
- // Note: selectedDump may be a slug, but lastCommentEvent.dumpId is always a UUID.
- // Compare against the loaded dump's actual ID.
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
useEffect(() => {
if (
@@ -190,7 +190,7 @@ export function Dump() {
if (dumpState.status === "loading") {
return (
- Loading dumpā¦
+ Loading dumpā¦
);
}
@@ -206,14 +206,14 @@ export function Dump() {
type="button"
onClick={() => globalThis.location.reload()}
>
- Retry
+ Retry
navigate("/")}
>
- ā Back to all dumps
+ ā Back to all dumps
>
}
@@ -245,7 +245,7 @@ export function Dump() {
className="btn-add-playlist"
onClick={() => setPlaylistModalOpen(true)}
>
- + Playlist
+ + Playlist
)}
@@ -271,14 +271,16 @@ export function Dump() {
{dump.updatedAt && (
-
+
- edited {relativeTime(dump.updatedAt)}
+ edited {relativeTime(dump.updatedAt)}
)}
{dump.isPrivate && (
- private
+
+ private
+
)}
@@ -291,7 +293,7 @@ export function Dump() {
{/* Main content */}
{dump.kind === "file"
- ?
+ ?
: dump.richContent
?
: (
@@ -308,8 +310,12 @@ export function Dump() {
{/* Actions */}
- {canEdit && Edit}
- ā Back to all dumps
+ {canEdit && (
+
+ Edit
+
+ )}
+ ā Back to all dumps
{/* Comments */}
diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx
index d15282e..a7cf44f 100644
--- a/src/pages/DumpEdit.tsx
+++ b/src/pages/DumpEdit.tsx
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
+import { t } from "@lingui/core/macro"
+import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
@@ -60,7 +62,7 @@ export function DumpEdit() {
setState({ status: "error", error: friendlyFetchError(err) });
}
})();
- }, [selectedDump]);
+ }, [selectedDump, token]);
const handleSave = async () => {
if (state.status !== "loaded") return;
@@ -138,7 +140,7 @@ export function DumpEdit() {
if (state.status === "loading") {
return (
- Loading dumpā¦
+ Loading dumpā¦
);
}
@@ -154,14 +156,14 @@ export function DumpEdit() {
type="button"
onClick={() => globalThis.location.reload()}
>
- Retry
+
Retry
navigate("/")}
>
- ā Back to all dumps
+ ā Back to all dumps
>
}
@@ -175,7 +177,7 @@ export function DumpEdit() {
-
Editing
+
Editing
{dump.title}
@@ -201,7 +203,7 @@ export function DumpEdit() {
onClick={handleRefreshMetadata}
disabled={refreshing}
>
- {refreshing ? "Refreshingā¦" : "Refresh metadata"}
+ {refreshing ?
Refreshing⦠:
Refresh metadata }
)}
@@ -216,7 +218,7 @@ export function DumpEdit() {
{dump.kind === "url"
? (
- URL
+ URL
)}
- Why are you dumping this?
+
+ Why are you dumping this?
+
@@ -260,14 +264,14 @@ export function DumpEdit() {
className={!isPrivate ? "active" : ""}
onClick={() => setIsPrivate(false)}
>
- Public
+ Public
setIsPrivate(true)}
>
- Private
+ Private
@@ -277,21 +281,23 @@ export function DumpEdit() {
onClick={() => setConfirmDelete(true)}
className="btn-danger"
>
- Delete dump
+ Delete dump
- Cancel
+ Cancel
- Save
+
+ Save
+