v3: added password change/reset feature

This commit is contained in:
khannurien
2026-04-06 16:30:00 +00:00
parent 3b6980a8fc
commit 20b9bfe7b4
26 changed files with 1268 additions and 236 deletions

View File

@@ -20,6 +20,13 @@ GERBEUR_PORT=8000
# Example: http://localhost:3000,http://127.0.0.1:3000
GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Base URL of the frontend, used in email links (e.g. password reset).
# Defaults to the API's own BASE_URL — correct when the API serves the frontend
# (standard single-container deployment). Override when running the frontend on
# a separate host or port (e.g. Vite dev server or a CDN origin).
# Example: http://localhost:3000
# GERBEUR_FRONTEND_URL=
# Secret key used to sign JWTs. Generate with: openssl rand -hex 32
GERBEUR_JWT_SECRET=

View File

@@ -1,6 +1,6 @@
# gerbeur
A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, …), vote, comment, follow each other, and build playlists. A real-time WebSocket layer handles live presence, vote counts, and notifications.
A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, comment, follow each other, build playlists, and search content. A real-time WebSocket layer handles live presence, vote counts, and notifications. The UI is localized (English and French) and ships multiple visual themes.
## Stack
@@ -38,14 +38,22 @@ Open [http://localhost:3000](http://localhost:3000). On first run a default `adm
See [`.env.example`](.env.example) for the full list with descriptions. Key variables:
| Variable | Description | Default |
| ------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- |
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
| `GERBEUR_SMTPS_URL` | SMTPS connection URL used by the email service (`smtps://user:pass@host:465`) | unset |
| `GERBEUR_SITE_NAME` | Site name used in OG meta tags | `gerbeur` |
| `GERBEUR_PORT` | API server port | `8000` |
| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of extra allowed frontend origins; the server's own `BASE_URL` is always allowed | `http://localhost:3000` |
| `VITE_API_HOSTNAME` | Override API hostname in the frontend bundle (see [Production](#production)) | unset |
| Variable | Description | Default |
| ---------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- |
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
| `GERBEUR_PROTOCOL` | Protocol the API server listens on (`http` or `https`) | `http` |
| `GERBEUR_HOSTNAME` | Public hostname used in generated URLs (e.g. OG image tags) | `localhost` |
| `GERBEUR_PORT` | API server port | `8000` |
| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` |
| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of extra allowed frontend origins; the server's own `BASE_URL` is always allowed | `http://localhost:3000` |
| `GERBEUR_FRONTEND_URL` | Frontend base URL used in email links (e.g. password reset); defaults to the API's own `BASE_URL` | `BASE_URL` |
| `GERBEUR_SITE_NAME` | Site name used in OG meta tags and emails | `gerbeur` |
| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`) | unset |
| `GERBEUR_FROM_EMAIL` | Sender address for outgoing emails — required when `GERBEUR_SMTPS_URL` is set | unset |
| `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template |
| `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` |
| `VITE_API_HOSTNAME` | API hostname baked into the frontend bundle | `localhost` |
| `VITE_API_PORT` | API port baked into the frontend bundle | `8000` |
## Production
@@ -114,7 +122,10 @@ src/ # React frontend (Vite)
config/api.ts # API base URL, validation constants
pages/ # Route-level components
components/ # Shared UI components
contexts/ # Auth, WebSocket, player, follows
contexts/ # Auth, WebSocket, player, follows, theme
hooks/ # Data fetching and UI hooks
locales/ # Lingui message catalogues (en, fr)
themes/ # Per-theme CSS files
i18n.ts # Lingui runtime setup
public/ # Static assets (favicon, icon sprite)
```

View File

@@ -19,6 +19,11 @@ export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || "";
// Set to 127.0.0.1 to restrict to loopback only.
export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0";
export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
// In single-container deployments the API serves the frontend, so FRONTEND_URL
// equals BASE_URL. Override with GERBEUR_FRONTEND_URL when running the frontend
// on a separate host/port (e.g. Vite dev server or a dedicated CDN origin).
export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() ||
BASE_URL;
export const DB_PATH = "api/sql/gerbeur.db";
// Upload/files

View File

@@ -6,6 +6,8 @@ import {
InvitePayload,
isAuthPayload,
isInvitePayload,
isPasswordResetPayload,
type PasswordResetPayload,
} from "../model/interfaces.ts";
import { JWT_SECRET } from "../config.ts";
@@ -38,6 +40,32 @@ export async function verifyInviteToken(
}
}
// ── Password reset tokens ─────────────────────────────────────────────────────
export async function createPasswordResetToken(
userId: string,
): Promise<string> {
return await new SignJWT({ purpose: "password-reset", userId })
.setProtectedHeader({ alg: "HS256" })
.setJti(crypto.randomUUID())
.setExpirationTime("1h")
.sign(JWT_KEY);
}
export async function verifyPasswordResetToken(
token: string,
): Promise<PasswordResetPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_KEY);
if (!isPasswordResetPayload(payload)) return null;
return payload as PasswordResetPayload;
} catch {
return null;
}
}
// ── Auth tokens ───────────────────────────────────────────────────────────────
export async function createJWT(
payload: Omit<AuthPayload, "exp">,
): Promise<string> {

View File

@@ -380,6 +380,27 @@ export function notificationRowToApi(row: NotificationRow): Notification {
};
}
// ── Password reset tokens ─────────────────────────────────────────────────────
export interface PasswordResetTokenRow {
token: string;
user_id: string;
expires_at: string;
used_at: string | null;
[key: string]: SQLOutputValue;
}
export function isPasswordResetTokenRow(
obj: unknown,
): obj is PasswordResetTokenRow {
return !!obj && typeof obj === "object" &&
"token" in obj && typeof obj.token === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"expires_at" in obj && typeof obj.expires_at === "string" &&
"used_at" in obj &&
(obj.used_at === null || typeof obj.used_at === "string");
}
// ── Invites ───────────────────────────────────────────────────────────────────
export interface InviteRow {

View File

@@ -177,6 +177,22 @@ export function isInvitePayload(obj: unknown): obj is InvitePayload {
typeof (obj as Record<string, unknown>).inviterId === "string";
}
export interface PasswordResetPayload {
purpose: "password-reset";
userId: string;
exp: number;
}
export function isPasswordResetPayload(
obj: unknown,
): obj is PasswordResetPayload {
return !!obj && typeof obj === "object" &&
"purpose" in obj &&
(obj as Record<string, unknown>).purpose === "password-reset" &&
"userId" in obj &&
typeof (obj as Record<string, unknown>).userId === "string";
}
/**
* API
*/

View File

@@ -22,8 +22,17 @@ import {
updateUser,
} from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
import {
requestPasswordReset,
resetPassword,
} from "../services/password-reset-service.ts";
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
import { FROM_EMAIL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts";
import {
FROM_EMAIL,
OG_SITE_NAME,
VALIDATION,
WELCOME_EMAIL_BODY,
} from "../config.ts";
import { marked } from "marked";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import {
@@ -163,6 +172,74 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
}
});
// Request a password reset email (unauthenticated; always returns 200)
router.post("/request-password-reset", async (ctx) => {
const body = await ctx.request.body.json();
const email = typeof body?.email === "string" ? body.email.trim() : "";
if (email) {
await requestPasswordReset(email).catch((err) =>
console.error("[request-password-reset]", err)
);
}
ctx.response.body = { success: true };
});
// Consume a reset token and set a new password (unauthenticated)
router.post("/reset-password", async (ctx) => {
const body = await ctx.request.body.json();
const { token, newPassword } = (body ?? {}) as Record<string, unknown>;
if (typeof token !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
await resetPassword(token, newPassword);
ctx.response.body = { success: true };
});
// Change current user's password (requires current password verification)
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json();
const { currentPassword, newPassword } = (body ?? {}) as Record<
string,
unknown
>;
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
if (
newPassword.length < VALIDATION.PASSWORD_MIN ||
newPassword.length > VALIDATION.PASSWORD_MAX
) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Password must be ${VALIDATION.PASSWORD_MIN}${VALIDATION.PASSWORD_MAX} characters`,
);
}
const user = getUserById(ctx.state.user.userId);
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Current password is incorrect",
);
}
await updateUser(ctx.state.user.userId, { password: newPassword });
ctx.response.body = { success: true };
});
// Update current user profile (description, etc.)
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json();

View File

@@ -0,0 +1,109 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isPasswordResetTokenRow } from "../model/db.ts";
import {
createPasswordResetToken,
verifyPasswordResetToken,
} from "../lib/jwt.ts";
import { getUserByEmail, updateUser } from "./user-service.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts";
import {
FROM_EMAIL,
FRONTEND_URL,
OG_SITE_NAME,
VALIDATION,
} from "../config.ts";
import { marked } from "marked";
const RESET_TOKEN_TTL_HOURS = 1;
/**
* Looks up the user by email, creates a signed reset token, persists it, and
* sends the reset link. Always resolves without throwing so callers can return
* 200 unconditionally (no user enumeration).
*/
export async function requestPasswordReset(email: string): Promise<void> {
const user = getUserByEmail(email);
if (!user) return;
if (!isEmailEnabled()) return;
const token = await createPasswordResetToken(user.id);
const expiresAt = new Date(
Date.now() + RESET_TOKEN_TTL_HOURS * 60 * 60 * 1000,
);
db.prepare(
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
).run(token, user.id, expiresAt.toISOString());
const resetUrl = `${FRONTEND_URL}/reset-password?token=${
encodeURIComponent(token)
}`;
const body =
`# Reset your ${OG_SITE_NAME} password\n\nHi **${user.username}**,\n\nClick the link below to set a new password. It expires in ${RESET_TOKEN_TTL_HOURS} hour.\n\n[Reset password](${resetUrl})\n\nIf you did not request this, ignore this email.`;
sendEmail({
from: FROM_EMAIL,
to: user.email,
subject: `Reset your ${OG_SITE_NAME} password`,
text: body,
html: await marked(body),
}).catch((err) =>
console.error("[password-reset] failed to send email:", err)
);
}
/**
* Verifies the token (JWT signature + expiry + DB record + not used), updates
* the password, and marks the token consumed.
*/
export async function resetPassword(
token: string,
newPassword: string,
): Promise<void> {
if (
newPassword.length < VALIDATION.PASSWORD_MIN ||
newPassword.length > VALIDATION.PASSWORD_MAX
) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Password must be ${VALIDATION.PASSWORD_MIN}${VALIDATION.PASSWORD_MAX} characters`,
);
}
const payload = await verifyPasswordResetToken(token);
if (!payload) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid or expired reset link",
);
}
const row = db.prepare(
`SELECT token, user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?;`,
).get(token);
if (!row || !isPasswordResetTokenRow(row)) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Reset token not found",
);
}
if (row.used_at !== null) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Reset link has already been used",
);
}
await updateUser(payload.userId, { password: newPassword });
db.prepare(
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ?;`,
).run(new Date().toISOString(), token);
}

View File

@@ -85,6 +85,15 @@ export function getUserByUsername(username: string): User {
return userRowToApi(userRow);
}
export function getUserByEmail(email: string): User | null {
const userRow = db.prepare(
`${USER_SELECT} WHERE u.email = ?`,
).get(email);
if (!userRow || !isUserRow(userRow)) return null;
return userRowToApi(userRow);
}
export function searchUsers(
query: string,
limit: number,

View File

@@ -126,6 +126,14 @@ CREATE TABLE attachments (
CREATE INDEX idx_attachments_resource ON attachments(resource_id);
CREATE TABLE password_reset_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,

View File

@@ -5,7 +5,7 @@
"server:start": "deno run --env-file -A --watch api/main.ts",
"serve": "deno task build && deno run -A server:start",
"i18n:extract": "deno run -A scripts/lingui-extract.ts",
"i18n:compile": "lingui compile"
"i18n:compile": "deno run -A scripts/lingui-compile.ts"
},
"nodeModulesDir": "auto",
"compilerOptions": {

View File

@@ -0,0 +1,5 @@
import { command as compile } from "../node_modules/@lingui/cli/dist/lingui-compile.js";
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
const config = getConfig({ cwd: Deno.cwd() });
await compile(config, { watch: false, namespace: undefined, typescript: false, allowEmpty: true, workersOptions: { poolSize: 0 } });

View File

@@ -2021,6 +2021,51 @@ body.has-player .fab-new {
margin: 0;
}
.auth-link-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-accent);
font-size: inherit;
font-family: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.auth-link-btn:hover {
opacity: 0.8;
}
.auth-reset-panel {
border-top: 1px solid var(--color-border-subtle);
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-reset-form {
margin: 0;
}
.auth-reset-sent {
font-size: 0.9rem;
text-align: center;
margin: 0;
opacity: 0.85;
}
.auth-field-hint {
display: block;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.auth-field-hint--error {
color: var(--color-error, #e53e3e);
}
/* ── Form pages (DumpCreate / DumpEdit) ── */
@keyframes page-enter {
from {

View File

@@ -14,6 +14,7 @@ import { UserPlaylists } from "./pages/UserPlaylists.tsx";
import { PlaylistDetail } from "./pages/PlaylistDetail.tsx";
import { Notifications } from "./pages/Notifications.tsx";
import { Search } from "./pages/Search.tsx";
import { ResetPassword } from "./pages/ResetPassword.tsx";
import { AuthProvider } from "./contexts/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
@@ -62,6 +63,7 @@ function AppRoutes() {
/>
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
<Route path="/search" element={<Search />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/notifications"
element={

View File

@@ -0,0 +1,186 @@
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { Modal } from "./Modal.tsx";
interface ChangePasswordModalProps {
onClose: () => void;
}
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
const { authFetch } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const mismatch = confirmPassword.length > 0 &&
newPassword !== confirmPassword;
const tooShort = newPassword.length > 0 &&
newPassword.length < VALIDATION.PASSWORD_MIN;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (mismatch || tooShort || !currentPassword || !newPassword) return;
setSubmitting(true);
setError(null);
try {
const res = await authFetch(
`${API_URL}/api/users/me/change-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
},
);
const body = await res.json();
if (!body.success) {
setError(body.error?.message ?? t`Unknown error`);
return;
}
setDone(true);
} catch {
setError(t`Failed to change password`);
} finally {
setSubmitting(false);
}
};
return (
<Modal title={t`Change password`} onClose={onClose}>
{done
? (
<div className="modal-new-playlist-form">
<p style={{ color: "var(--color-success, green)" }}>
<Trans>Password changed successfully.</Trans>
</p>
<div className="form-actions">
<div className="form-actions-right">
<button type="button" className="btn-primary" onClick={onClose}>
<Trans>Close</Trans>
</button>
</div>
</div>
</div>
)
: (
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<label>
<span
style={{
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}}
>
<Trans>Current password</Trans>
</span>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
autoFocus
/>
</label>
<label>
<span
style={{
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}}
>
<Trans>New password</Trans>
</span>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
required
/>
{tooShort && (
<span
style={{
fontSize: "0.8rem",
color: "var(--color-error, red)",
marginTop: "0.2rem",
display: "block",
}}
>
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
</span>
)}
</label>
<label>
<span
style={{
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}}
>
<Trans>Confirm new password</Trans>
</span>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
{mismatch && (
<span
style={{
fontSize: "0.8rem",
color: "var(--color-error, red)",
marginTop: "0.2rem",
display: "block",
}}
>
<Trans>Passwords do not match</Trans>
</span>
)}
</label>
{error && (
<ErrorCard title={t`Could not change password`} message={error} />
)}
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="form-cancel"
onClick={onClose}
>
<Trans>Cancel</Trans>
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting || mismatch || tooShort ||
!currentPassword || !newPassword || !confirmPassword}
>
{submitting
? <Trans>Saving</Trans>
: <Trans>Change password</Trans>}
</button>
</div>
</div>
</form>
)}
</Modal>
);
}

View File

@@ -18,7 +18,8 @@ import {
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { dumpUrl } from "../utils/urls.ts";
import { dumpUrl, normalizeUrl } from "../utils/urls.ts";
import { MAX_FILE_SIZE } from "../config/upload.ts";
import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts";
@@ -29,14 +30,6 @@ import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
function normalizeUrl(input: string): string {
const s = input.trim();
if (!s || /^https?:\/\//i.test(s)) return s;
if (s.startsWith("//")) return `https:${s}`;
return `https://${s}`;
}
import { MAX_FILE_SIZE } from "../config/upload.ts";
type Mode = "url" | "file";
type Phase = "create" | "playlist";

View File

@@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { formatBytes } from "../utils/format.ts";
import { MediaPlayer } from "./MediaPlayer.tsx";
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts";
import {
BAR_GAP,
@@ -76,26 +76,7 @@ function AudioFilePreview(
onClick={handlePlayBtn}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying
? (
<svg
viewBox="0 0 24 24"
fill="currentColor"
style={{ padding: "1px" }}
>
<rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" />
</svg>
)
: (
<svg
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginLeft: "2px" }}
>
<polygon points="6,3 20,12 6,21" />
</svg>
)}
{isPlaying ? <IconPause /> : <IconPlay />}
</button>
{peaks
? (

View File

@@ -15,13 +15,13 @@ function fmt(s: number): string {
return `${m}:${sec.toString().padStart(2, "0")}`;
}
const IconPlay = () => (
export const IconPlay = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
<polygon points="6,3 20,12 6,21" />
</svg>
);
const IconPause = () => (
export const IconPause = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
<rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" />

File diff suppressed because one or more lines are too long

View File

@@ -54,7 +54,7 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#: src/pages/PlaylistDetail.tsx:611
#: src/pages/UserPublicProfile.tsx:728
#: src/pages/UserPublicProfile.tsx:745
msgid "← Back"
msgstr "← Back"
@@ -70,7 +70,7 @@ msgstr "← Back to all dumps"
msgid "← Back to profile"
msgstr "← Back to profile"
#: src/pages/UserPublicProfile.tsx:93
#: src/pages/UserPublicProfile.tsx:100
msgid "+ Invite someone"
msgstr "+ Invite someone"
@@ -79,7 +79,7 @@ msgid "+ New"
msgstr "+ New"
#: src/pages/UserDumps.tsx:114
#: src/pages/UserPublicProfile.tsx:1282
#: src/pages/UserPublicProfile.tsx:1330
msgid "+ New dump"
msgstr "+ New dump"
@@ -134,7 +134,11 @@ msgstr "a comment"
msgid "a post"
msgstr "a post"
#: src/pages/UserPublicProfile.tsx:931
#: src/pages/UserPublicProfile.tsx:1215
msgid "Account"
msgstr "Account"
#: src/pages/UserPublicProfile.tsx:948
msgid "Add a bio…"
msgstr "Add a bio…"
@@ -142,12 +146,12 @@ msgstr "Add a bio…"
msgid "Add a comment…"
msgstr "Add a comment…"
#: src/pages/UserPublicProfile.tsx:842
#: src/pages/UserPublicProfile.tsx:859
msgid "Add email…"
msgstr "Add email…"
#: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:284
#: src/components/DumpCreateModal.tsx:277
msgid "Add to playlist"
msgstr "Add to playlist"
@@ -167,29 +171,41 @@ msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>"
#: src/pages/UserPublicProfile.tsx:1186
#: src/pages/UserPublicProfile.tsx:1234
msgid "Appearance"
msgstr "Appearance"
#: src/pages/UserPublicProfile.tsx:1220
#. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/ChangePasswordModal.tsx:101
#: src/pages/ResetPassword.tsx:113
msgid "At least {0} characters"
msgstr "At least {0} characters"
#: src/pages/UserPublicProfile.tsx:1268
msgid "Auto"
msgstr "Auto"
#: src/pages/ResetPassword.tsx:36
#: src/pages/ResetPassword.tsx:146
msgid "Back to login"
msgstr "Back to login"
#: 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/ChangePasswordModal.tsx:132
#: src/components/CommentThread.tsx:281
#: src/components/CommentThread.tsx:373
#: src/components/CommentThread.tsx:510
#: src/components/ConfirmModal.tsx:32
#: src/components/DumpCreateModal.tsx:422
#: src/components/DumpCreateModal.tsx:415
#: src/components/PlaylistCreateForm.tsx:112
#: src/pages/DumpEdit.tsx:299
#: src/pages/PlaylistDetail.tsx:680
#: src/pages/UserPublicProfile.tsx:824
#: src/pages/UserPublicProfile.tsx:902
#: src/pages/UserPublicProfile.tsx:841
#: src/pages/UserPublicProfile.tsx:919
msgid "Cancel"
msgstr "Cancel"
@@ -201,19 +217,29 @@ msgstr "Cancel removal"
#~ msgid "Cannot edit a deleted comment"
#~ msgstr "Cannot edit a deleted comment"
#: src/pages/UserPublicProfile.tsx:755
#: src/pages/UserPublicProfile.tsx:772
msgid "Change avatar"
msgstr "Change avatar"
#: src/components/ChangePasswordModal.tsx:55
#: src/components/ChangePasswordModal.tsx:142
msgid "Change password"
msgstr "Change password"
#: src/pages/UserPublicProfile.tsx:1227
msgid "Change password…"
msgstr "Change password…"
#: src/pages/UserRegister.tsx:95
msgid "Checking invite…"
msgstr "Checking invite…"
#: src/components/ChangePasswordModal.tsx:65
#: src/components/Modal.tsx:45
msgid "Close"
msgstr "Close"
#: src/pages/UserPublicProfile.tsx:1212
#: src/pages/UserPublicProfile.tsx:1260
msgid "Color scheme"
msgstr "Color scheme"
@@ -221,14 +247,28 @@ msgstr "Color scheme"
#~ msgid "Comment not found"
#~ msgstr "Comment not found"
#: src/pages/UserPublicProfile.tsx:84
#: src/components/ChangePasswordModal.tsx:107
#: src/pages/ResetPassword.tsx:120
msgid "Confirm new password"
msgstr "Confirm new password"
#: src/pages/UserPublicProfile.tsx:91
msgid "Copied!"
msgstr "Copied!"
#: src/pages/UserPublicProfile.tsx:84
#: src/pages/UserPublicProfile.tsx:91
msgid "Copy"
msgstr "Copy"
#: src/components/ChangePasswordModal.tsx:123
msgid "Could not change password"
msgstr "Could not change password"
#: src/pages/ResetPassword.tsx:84
#: src/pages/UserLogin.tsx:79
msgid "Could not connect to server"
msgstr "Could not connect to server"
#: src/components/CommentThread.tsx:111
#: src/components/CommentThread.tsx:153
#: src/components/CommentThread.tsx:448
@@ -253,7 +293,11 @@ msgstr "Created ({0}{1})"
msgid "Creating…"
msgstr "Creating…"
#: src/pages/UserPublicProfile.tsx:1234
#: src/components/ChangePasswordModal.tsx:75
msgid "Current password"
msgstr "Current password"
#: src/pages/UserPublicProfile.tsx:1282
msgid "Dark"
msgstr "Dark"
@@ -293,7 +337,7 @@ msgstr "Delete this playlist? This cannot be undone."
msgid "Description (optional)"
msgstr "Description (optional)"
#: src/components/DumpCreateModal.tsx:468
#: src/components/DumpCreateModal.tsx:461
msgid "Done"
msgstr "Done"
@@ -305,7 +349,7 @@ msgstr "Drop a file here"
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
#: src/components/DumpCreateModal.tsx:434
#: src/components/DumpCreateModal.tsx:427
msgid "Dump it"
msgstr "Dump it"
@@ -313,19 +357,19 @@ msgstr "Dump it"
#~ msgid "Dump not found"
#~ msgstr "Dump not found"
#: src/components/DumpCreateModal.tsx:445
#: src/components/DumpCreateModal.tsx:438
msgid "Dumped!"
msgstr "Dumped!"
#: src/pages/Search.tsx:172
#: src/pages/UserDumps.tsx:107
#: src/pages/UserPublicProfile.tsx:950
#: src/pages/UserPublicProfile.tsx:967
msgid "Dumps"
msgstr "Dumps"
#. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:987
#: src/pages/UserPublicProfile.tsx:1004
msgid "Dumps ({0}{1})"
msgstr "Dumps ({0}{1})"
@@ -369,14 +413,18 @@ msgstr "Email address"
msgid "Enter a query to search."
msgstr "Enter a query to search."
#: src/components/ChangePasswordModal.tsx:48
msgid "Failed to change password"
msgstr "Failed to change password"
#: src/components/PlaylistCreateForm.tsx:62
#: src/components/PlaylistCreateForm.tsx:103
msgid "Failed to create playlist"
msgstr "Failed to create playlist"
#: src/pages/UserPublicProfile.tsx:65
#: src/pages/UserPublicProfile.tsx:68
#: src/pages/UserPublicProfile.tsx:96
#: src/pages/UserPublicProfile.tsx:72
#: src/pages/UserPublicProfile.tsx:75
#: src/pages/UserPublicProfile.tsx:103
msgid "Failed to generate invite"
msgstr "Failed to generate invite"
@@ -385,13 +433,13 @@ msgstr "Failed to generate invite"
#: src/pages/index/JournalFeed.tsx:48
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:323
#: src/pages/UserPublicProfile.tsx:1081
#: src/pages/UserPublicProfile.tsx:1118
#: src/pages/UserPublicProfile.tsx:1160
#: src/pages/UserPublicProfile.tsx:1106
#: src/pages/UserPublicProfile.tsx:1148
#: src/pages/UserPublicProfile.tsx:1193
msgid "Failed to load"
msgstr "Failed to load"
#: src/components/DumpCreateModal.tsx:322
#: src/components/DumpCreateModal.tsx:315
msgid "Failed to post"
msgstr "Failed to post"
@@ -404,10 +452,10 @@ msgid "Failed to post reply"
msgstr "Failed to post reply"
#: src/pages/PlaylistDetail.tsx:789
#: src/pages/UserPublicProfile.tsx:663
#: src/pages/UserPublicProfile.tsx:701
#: src/pages/UserPublicProfile.tsx:828
#: src/pages/UserPublicProfile.tsx:905
#: src/pages/UserPublicProfile.tsx:680
#: src/pages/UserPublicProfile.tsx:718
#: src/pages/UserPublicProfile.tsx:845
#: src/pages/UserPublicProfile.tsx:922
msgid "Failed to save"
msgstr "Failed to save"
@@ -415,19 +463,19 @@ msgstr "Failed to save"
msgid "Failed to save edit"
msgstr "Failed to save edit"
#: src/pages/UserPublicProfile.tsx:851
#: src/pages/UserPublicProfile.tsx:868
msgid "Failed to update avatar"
msgstr "Failed to update avatar"
#: src/components/DumpCreateModal.tsx:359
#: src/components/DumpCreateModal.tsx:352
msgid "Fetching preview…"
msgstr "Fetching preview…"
#: src/components/DumpCreateModal.tsx:432
#: src/components/DumpCreateModal.tsx:425
msgid "Fetching…"
msgstr "Fetching…"
#: src/components/DumpCreateModal.tsx:315
#: src/components/DumpCreateModal.tsx:308
#: src/components/FileDropZone.tsx:31
msgid "File"
msgstr "File"
@@ -444,7 +492,7 @@ msgstr "File"
#~ msgid "File too large (max 50 MB)"
#~ msgstr "File too large (max 50 MB)"
#: src/components/DumpCreateModal.tsx:209
#: src/components/DumpCreateModal.tsx:202
msgid "File too large (max 50 MB)."
msgstr "File too large (max 50 MB)."
@@ -470,7 +518,7 @@ msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here."
#: src/components/FeedTabBar.tsx:47
#: src/pages/UserPublicProfile.tsx:964
#: src/pages/UserPublicProfile.tsx:981
msgid "Followed"
msgstr "Followed"
@@ -480,13 +528,13 @@ msgstr "Followed"
msgid "Followed ({0}{1})"
msgstr "Followed ({0}{1})"
#: src/pages/UserPublicProfile.tsx:1109
#: src/pages/UserPublicProfile.tsx:1137
msgid "Followed playlists"
msgstr "Followed playlists"
#: src/components/FollowButton.tsx:37
#: src/components/FollowButton.tsx:64
#: src/pages/UserPublicProfile.tsx:1072
#: src/pages/UserPublicProfile.tsx:1095
msgid "Following"
msgstr "Following"
@@ -494,6 +542,10 @@ msgstr "Following"
#~ msgid "Forbidden"
#~ msgstr "Forbidden"
#: src/pages/UserLogin.tsx:131
msgid "Forgot password?"
msgstr "Forgot password?"
#: src/pages/index/FollowedFeed.tsx:337
msgid "From people"
msgstr "From people"
@@ -502,10 +554,18 @@ msgstr "From people"
msgid "From playlists"
msgstr "From playlists"
#: src/pages/ResetPassword.tsx:56
msgid "Go to login"
msgstr "Go to login"
#: src/components/FeedTabBar.tsx:25
msgid "Hot"
msgstr "Hot"
#: src/pages/UserLogin.tsx:140
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly."
#: api/auth:
#~ msgid "Invalid email address"
#~ msgstr "Invalid email address"
@@ -514,6 +574,10 @@ msgstr "Hot"
msgid "Invalid invite"
msgstr "Invalid invite"
#: src/pages/ResetPassword.tsx:33
msgid "Invalid link"
msgstr "Invalid link"
#: api/invites:
#~ msgid "Invalid or expired invite"
#~ msgstr "Invalid or expired invite"
@@ -531,12 +595,12 @@ msgstr "Invalid invite"
#~ msgid "Invite already used"
#~ msgstr "Invite already used"
#: src/pages/UserPublicProfile.tsx:773
#: src/pages/UserPublicProfile.tsx:790
msgid "invited by"
msgstr "invited by"
#: src/pages/UserPublicProfile.tsx:971
#: src/pages/UserPublicProfile.tsx:1149
#: src/pages/UserPublicProfile.tsx:988
#: src/pages/UserPublicProfile.tsx:1182
msgid "Invitees"
msgstr "Invitees"
@@ -548,7 +612,7 @@ msgstr "Journal"
msgid "just now"
msgstr "just now"
#: src/pages/UserPublicProfile.tsx:1227
#: src/pages/UserPublicProfile.tsx:1275
msgid "Light"
msgstr "Light"
@@ -585,7 +649,7 @@ msgstr "Loading more…"
msgid "Loading playlist…"
msgstr "Loading playlist…"
#: src/pages/UserPublicProfile.tsx:711
#: src/pages/UserPublicProfile.tsx:728
msgid "Loading profile…"
msgstr "Loading profile…"
@@ -599,29 +663,29 @@ msgstr "Loading profile…"
#: src/pages/Notifications.tsx:395
#: src/pages/UserDumps.tsx:51
#: src/pages/UserPlaylists.tsx:342
#: src/pages/UserPublicProfile.tsx:1077
#: src/pages/UserPublicProfile.tsx:1114
#: src/pages/UserPublicProfile.tsx:1154
#: src/pages/UserPublicProfile.tsx:1100
#: src/pages/UserPublicProfile.tsx:1142
#: src/pages/UserPublicProfile.tsx:1187
#: src/pages/UserUpvoted.tsx:123
msgid "Loading…"
msgstr "Loading…"
#: src/components/AppHeader.tsx:74
#: src/pages/UserLogin.tsx:63
#: src/pages/UserLogin.tsx:93
#: src/pages/UserLogin.tsx:87
#: src/pages/UserLogin.tsx:117
msgid "Log in"
msgstr "Log in"
#: src/pages/UserPublicProfile.tsx:732
#: src/pages/UserPublicProfile.tsx:865
#: src/pages/UserPublicProfile.tsx:749
#: src/pages/UserPublicProfile.tsx:882
msgid "Log out"
msgstr "Log out"
#: src/pages/UserLogin.tsx:92
#: src/pages/UserLogin.tsx:116
msgid "Logging in…"
msgstr "Logging in…"
#: src/pages/UserLogin.tsx:67
#: src/pages/UserLogin.tsx:91
msgid "Login failed"
msgstr "Login failed"
@@ -637,10 +701,15 @@ msgstr "new"
msgid "New"
msgstr "New"
#: src/components/DumpCreateModal.tsx:284
#: src/components/DumpCreateModal.tsx:277
msgid "New dump"
msgstr "New dump"
#: src/components/ChangePasswordModal.tsx:88
#: src/pages/ResetPassword.tsx:101
msgid "New password"
msgstr "New password"
#: src/components/NewPlaylistForm.tsx:34
msgid "New playlist"
msgstr "New playlist"
@@ -664,11 +733,11 @@ msgid "No emoji found."
msgstr "No emoji found."
#: src/pages/UserPlaylists.tsx:439
#: src/pages/UserPublicProfile.tsx:1122
#: src/pages/UserPublicProfile.tsx:1155
msgid "No followed playlists yet."
msgstr "No followed playlists yet."
#: src/pages/UserPublicProfile.tsx:1167
#: src/pages/UserPublicProfile.tsx:1200
msgid "No invitees yet."
msgstr "No invitees yet."
@@ -678,7 +747,7 @@ msgstr "No playlists match \"{q}\"."
#: src/components/PlaylistMembershipPanel.tsx:34
#: src/pages/UserPlaylists.tsx:397
#: src/pages/UserPublicProfile.tsx:1043
#: src/pages/UserPublicProfile.tsx:1066
msgid "No playlists yet."
msgstr "No playlists yet."
@@ -690,14 +759,14 @@ msgstr "No users match \"{q}\"."
#~ msgid "Not authenticated"
#~ msgstr "Not authenticated"
#: src/pages/UserPublicProfile.tsx:1085
#: src/pages/UserPublicProfile.tsx:1113
msgid "Not following anyone yet."
msgstr "Not following anyone yet."
#: src/pages/Notifications.tsx:330
#: src/pages/UserDumps.tsx:123
#: src/pages/UserPublicProfile.tsx:1292
#: src/pages/UserPublicProfile.tsx:1415
#: src/pages/UserPublicProfile.tsx:1340
#: src/pages/UserPublicProfile.tsx:1463
#: src/pages/UserUpvoted.tsx:195
msgid "Nothing here yet."
msgstr "Nothing here yet."
@@ -719,7 +788,8 @@ msgstr "Open search"
msgid "or <0>browse files</0>"
msgstr "or <0>browse files</0>"
#: src/pages/UserLogin.tsx:82
#: src/pages/UserLogin.tsx:106
#: src/pages/UserPublicProfile.tsx:1220
msgid "Password"
msgstr "Password"
@@ -728,6 +798,10 @@ msgstr "Password"
msgid "Password (min. {0} characters)"
msgstr "Password (min. {0} characters)"
#: src/components/ChangePasswordModal.tsx:60
msgid "Password changed successfully."
msgstr "Password changed successfully."
#: api/auth:
#~ msgid "Password must be at least 8 characters"
#~ msgstr "Password must be at least 8 characters"
@@ -736,6 +810,15 @@ msgstr "Password (min. {0} characters)"
#~ msgid "Password must be at most 128 characters"
#~ msgstr "Password must be at most 128 characters"
#: src/pages/ResetPassword.tsx:47
msgid "Password updated"
msgstr "Password updated"
#: src/components/ChangePasswordModal.tsx:118
#: src/pages/ResetPassword.tsx:129
msgid "Passwords do not match"
msgstr "Passwords do not match"
#: api/playlists:
#~ msgid "Playlist not found"
#~ msgstr "Playlist not found"
@@ -744,17 +827,17 @@ msgstr "Password (min. {0} characters)"
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:175
#: src/pages/UserPlaylists.tsx:368
#: src/pages/UserPublicProfile.tsx:957
#: src/pages/UserPublicProfile.tsx:974
msgid "Playlists"
msgstr "Playlists"
#. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:1016
#: src/pages/UserPublicProfile.tsx:1035
msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})"
#: src/components/DumpCreateModal.tsx:202
#: src/components/DumpCreateModal.tsx:195
msgid "Please select a file."
msgstr "Please select a file."
@@ -779,7 +862,7 @@ msgstr "Posting…"
msgid "private"
msgstr "private"
#: src/components/DumpCreateModal.tsx:411
#: src/components/DumpCreateModal.tsx:404
#: src/components/PlaylistCreateForm.tsx:99
#: src/pages/DumpEdit.tsx:285
#: src/pages/PlaylistDetail.tsx:746
@@ -791,7 +874,7 @@ msgstr "Private"
msgid "public"
msgstr "public"
#: src/components/DumpCreateModal.tsx:403
#: src/components/DumpCreateModal.tsx:396
#: src/components/PlaylistCreateForm.tsx:92
#: src/pages/DumpEdit.tsx:278
#: src/pages/PlaylistDetail.tsx:739
@@ -835,6 +918,14 @@ msgstr "Replace file"
msgid "Reply"
msgstr "Reply"
#: src/pages/UserLogin.tsx:150
msgid "Request failed"
msgstr "Request failed"
#: src/pages/ResetPassword.tsx:94
msgid "Reset failed"
msgstr "Reset failed"
#: src/pages/Dump.tsx:211
#: src/pages/DumpEdit.tsx:163
msgid "Retry"
@@ -843,15 +934,17 @@ msgstr "Retry"
#: src/components/CommentThread.tsx:270
#: src/pages/DumpEdit.tsx:306
#: src/pages/PlaylistDetail.tsx:673
#: src/pages/UserPublicProfile.tsx:816
#: src/pages/UserPublicProfile.tsx:894
#: src/pages/UserPublicProfile.tsx:833
#: src/pages/UserPublicProfile.tsx:911
msgid "Save"
msgstr "Save"
#: src/components/ChangePasswordModal.tsx:141
#: src/components/CommentThread.tsx:269
#: src/pages/PlaylistDetail.tsx:673
#: src/pages/UserPublicProfile.tsx:815
#: src/pages/UserPublicProfile.tsx:894
#: src/pages/ResetPassword.tsx:140
#: src/pages/UserPublicProfile.tsx:832
#: src/pages/UserPublicProfile.tsx:911
msgid "Saving…"
msgstr "Saving…"
@@ -871,11 +964,24 @@ msgstr "Search failed"
msgid "Searching…"
msgstr "Searching…"
#: src/pages/UserLogin.tsx:175
msgid "Send reset link"
msgstr "Send reset link"
#: src/pages/UserLogin.tsx:174
msgid "Sending…"
msgstr "Sending…"
#: src/components/AppHeader.tsx:65
msgid "Server unreachable"
msgstr "Server unreachable"
#: src/pages/UserPublicProfile.tsx:979
#: src/pages/ResetPassword.tsx:91
#: src/pages/ResetPassword.tsx:141
msgid "Set new password"
msgstr "Set new password"
#: src/pages/UserPublicProfile.tsx:996
msgid "Settings"
msgstr "Settings"
@@ -883,7 +989,7 @@ msgstr "Settings"
msgid "Something went wrong"
msgstr "Something went wrong"
#: src/pages/UserPublicProfile.tsx:1191
#: src/pages/UserPublicProfile.tsx:1239
msgid "Style"
msgstr "Style"
@@ -891,11 +997,11 @@ msgstr "Style"
msgid "Submit search"
msgstr "Submit search"
#: src/pages/UserPublicProfile.tsx:882
#: src/pages/UserPublicProfile.tsx:899
msgid "Tell people about yourself…"
msgstr "Tell people about yourself…"
#: src/components/DumpCreateModal.tsx:390
#: src/components/DumpCreateModal.tsx:383
#: src/pages/DumpEdit.tsx:266
msgid "Tell the community what makes this worth their time..."
msgstr "Tell the community what makes this worth their time..."
@@ -904,10 +1010,14 @@ msgstr "Tell the community what makes this worth their time..."
msgid "This invite link is missing, expired, or already used."
msgstr "This invite link is missing, expired, or already used."
#: src/pages/UserLogin.tsx:98
#: src/pages/UserLogin.tsx:184
msgid "This is a mirage."
msgstr "This is a mirage."
#: src/pages/ResetPassword.tsx:34
msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed."
#: src/components/PlaylistCreateForm.tsx:72
msgid "Title"
msgstr "Title"
@@ -932,11 +1042,16 @@ msgstr "Unfollow {targetUsername}"
msgid "Unfollow playlist"
msgstr "Unfollow playlist"
#: src/pages/UserPublicProfile.tsx:632
#: src/components/ChangePasswordModal.tsx:43
#: src/pages/ResetPassword.tsx:80
msgid "Unknown error"
msgstr "Unknown error"
#: src/pages/UserPublicProfile.tsx:649
msgid "Upload failed"
msgstr "Upload failed"
#: src/components/DumpCreateModal.tsx:433
#: src/components/DumpCreateModal.tsx:426
msgid "Uploading…"
msgstr "Uploading…"
@@ -946,16 +1061,16 @@ msgstr "Upvoted"
#. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:998
#: src/pages/UserPublicProfile.tsx:1015
msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:332
#: src/components/DumpCreateModal.tsx:325
#: src/pages/DumpEdit.tsx:230
msgid "URL"
msgstr "URL"
#: src/components/DumpCreateModal.tsx:185
#: src/components/DumpCreateModal.tsx:178
msgid "URL is required."
msgstr "URL is required."
@@ -963,7 +1078,7 @@ msgstr "URL is required."
msgid "User menu"
msgstr "User menu"
#: src/pages/UserLogin.tsx:74
#: src/pages/UserLogin.tsx:98
#: src/pages/UserRegister.tsx:129
msgid "Username"
msgstr "Username"
@@ -980,19 +1095,19 @@ msgstr "Username"
msgid "Users"
msgstr "Users"
#: src/pages/UserPublicProfile.tsx:1062
#: src/pages/UserPublicProfile.tsx:1100
#: src/pages/UserPublicProfile.tsx:1137
#: src/pages/UserPublicProfile.tsx:1313
#: src/pages/UserPublicProfile.tsx:1445
#: src/pages/UserPublicProfile.tsx:1085
#: src/pages/UserPublicProfile.tsx:1128
#: src/pages/UserPublicProfile.tsx:1170
#: src/pages/UserPublicProfile.tsx:1361
#: src/pages/UserPublicProfile.tsx:1493
msgid "View all →"
msgstr "View all →"
#: src/components/DumpCreateModal.tsx:447
#: src/components/DumpCreateModal.tsx:440
msgid "View dump →"
msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:383
#: src/components/DumpCreateModal.tsx:376
#: src/pages/DumpEdit.tsx:260
msgid "Why are you dumping this?"
msgstr "Why are you dumping this?"
@@ -1020,3 +1135,11 @@ msgstr "You'll be notified when someone follows your playlists, upvotes your dum
#: src/pages/UserUpvoted.tsx:182
msgid "You've reached the end."
msgstr "You've reached the end."
#: src/pages/UserLogin.tsx:160
msgid "Your email address"
msgstr "Your email address"
#: src/pages/ResetPassword.tsx:49
msgid "Your password has been changed. You can now log in."
msgstr "Your password has been changed. You can now log in."

File diff suppressed because one or more lines are too long

View File

@@ -54,7 +54,7 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#: src/pages/PlaylistDetail.tsx:611
#: src/pages/UserPublicProfile.tsx:728
#: src/pages/UserPublicProfile.tsx:745
msgid "← Back"
msgstr "← Retour"
@@ -70,7 +70,7 @@ msgstr "← Retour à toutes les recos"
msgid "← Back to profile"
msgstr "← Retour au profil"
#: src/pages/UserPublicProfile.tsx:93
#: src/pages/UserPublicProfile.tsx:100
msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un"
@@ -79,7 +79,7 @@ msgid "+ New"
msgstr "+ Nouveau"
#: src/pages/UserDumps.tsx:114
#: src/pages/UserPublicProfile.tsx:1282
#: src/pages/UserPublicProfile.tsx:1330
msgid "+ New dump"
msgstr "+ Nouvelle reco"
@@ -134,7 +134,11 @@ msgstr "un commentaire"
msgid "a post"
msgstr "une publication"
#: src/pages/UserPublicProfile.tsx:931
#: src/pages/UserPublicProfile.tsx:1215
msgid "Account"
msgstr "Compte"
#: src/pages/UserPublicProfile.tsx:948
msgid "Add a bio…"
msgstr "Ajouter une bio…"
@@ -142,12 +146,12 @@ msgstr "Ajouter une bio…"
msgid "Add a comment…"
msgstr "Ajouter un commentaire…"
#: src/pages/UserPublicProfile.tsx:842
#: src/pages/UserPublicProfile.tsx:859
msgid "Add email…"
msgstr "Ajouter un e-mail…"
#: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:284
#: src/components/DumpCreateModal.tsx:277
msgid "Add to playlist"
msgstr "Ajouter à la collection"
@@ -163,29 +167,41 @@ msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} char
msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
#: src/pages/UserPublicProfile.tsx:1186
#: src/pages/UserPublicProfile.tsx:1234
msgid "Appearance"
msgstr "Apparence"
#: src/pages/UserPublicProfile.tsx:1220
#. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/ChangePasswordModal.tsx:101
#: src/pages/ResetPassword.tsx:113
msgid "At least {0} characters"
msgstr "Au moins {0} caractères"
#: src/pages/UserPublicProfile.tsx:1268
msgid "Auto"
msgstr "Auto"
#: src/pages/ResetPassword.tsx:36
#: src/pages/ResetPassword.tsx:146
msgid "Back to login"
msgstr "Retour à la connexion"
#: 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/ChangePasswordModal.tsx:132
#: src/components/CommentThread.tsx:281
#: src/components/CommentThread.tsx:373
#: src/components/CommentThread.tsx:510
#: src/components/ConfirmModal.tsx:32
#: src/components/DumpCreateModal.tsx:422
#: src/components/DumpCreateModal.tsx:415
#: src/components/PlaylistCreateForm.tsx:112
#: src/pages/DumpEdit.tsx:299
#: src/pages/PlaylistDetail.tsx:680
#: src/pages/UserPublicProfile.tsx:824
#: src/pages/UserPublicProfile.tsx:902
#: src/pages/UserPublicProfile.tsx:841
#: src/pages/UserPublicProfile.tsx:919
msgid "Cancel"
msgstr "Annuler"
@@ -193,30 +209,54 @@ msgstr "Annuler"
msgid "Cancel removal"
msgstr "Annuler la suppression"
#: src/pages/UserPublicProfile.tsx:755
#: src/pages/UserPublicProfile.tsx:772
msgid "Change avatar"
msgstr "Changer l'avatar"
#: src/components/ChangePasswordModal.tsx:55
#: src/components/ChangePasswordModal.tsx:142
msgid "Change password"
msgstr "Changer le mot de passe"
#: src/pages/UserPublicProfile.tsx:1227
msgid "Change password…"
msgstr "Changer le mot de passe…"
#: src/pages/UserRegister.tsx:95
msgid "Checking invite…"
msgstr "Vérification de l'invitation…"
#: src/components/ChangePasswordModal.tsx:65
#: src/components/Modal.tsx:45
msgid "Close"
msgstr "Fermer"
#: src/pages/UserPublicProfile.tsx:1212
#: src/pages/UserPublicProfile.tsx:1260
msgid "Color scheme"
msgstr "Thème de couleur"
#: src/pages/UserPublicProfile.tsx:84
#: src/components/ChangePasswordModal.tsx:107
#: src/pages/ResetPassword.tsx:120
msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe"
#: src/pages/UserPublicProfile.tsx:91
msgid "Copied!"
msgstr "Copié !"
#: src/pages/UserPublicProfile.tsx:84
#: src/pages/UserPublicProfile.tsx:91
msgid "Copy"
msgstr "Copier"
#: src/components/ChangePasswordModal.tsx:123
msgid "Could not change password"
msgstr "Impossible de changer le mot de passe"
#: src/pages/ResetPassword.tsx:84
#: src/pages/UserLogin.tsx:79
msgid "Could not connect to server"
msgstr "Impossible de contacter le serveur"
#: src/components/CommentThread.tsx:111
#: src/components/CommentThread.tsx:153
#: src/components/CommentThread.tsx:448
@@ -241,7 +281,11 @@ msgstr "Créées ({0}{1})"
msgid "Creating…"
msgstr "Création…"
#: src/pages/UserPublicProfile.tsx:1234
#: src/components/ChangePasswordModal.tsx:75
msgid "Current password"
msgstr "Mot de passe actuel"
#: src/pages/UserPublicProfile.tsx:1282
msgid "Dark"
msgstr "Sombre"
@@ -281,7 +325,7 @@ msgstr "Supprimer cette collection ? Cette action est irréversible."
msgid "Description (optional)"
msgstr "Description (facultatif)"
#: src/components/DumpCreateModal.tsx:468
#: src/components/DumpCreateModal.tsx:461
msgid "Done"
msgstr "Terminé"
@@ -293,23 +337,23 @@ msgstr "Déposez un fichier ici"
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
#: src/components/DumpCreateModal.tsx:434
#: src/components/DumpCreateModal.tsx:427
msgid "Dump it"
msgstr "Recommander"
#: src/components/DumpCreateModal.tsx:445
#: src/components/DumpCreateModal.tsx:438
msgid "Dumped!"
msgstr "Recommandé !"
#: src/pages/Search.tsx:172
#: src/pages/UserDumps.tsx:107
#: src/pages/UserPublicProfile.tsx:950
#: src/pages/UserPublicProfile.tsx:967
msgid "Dumps"
msgstr "Recos"
#. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:987
#: src/pages/UserPublicProfile.tsx:1004
msgid "Dumps ({0}{1})"
msgstr "Recos ({0}{1})"
@@ -353,14 +397,18 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
#: src/components/ChangePasswordModal.tsx:48
msgid "Failed to change password"
msgstr "Impossible de changer le mot de passe"
#: src/components/PlaylistCreateForm.tsx:62
#: src/components/PlaylistCreateForm.tsx:103
msgid "Failed to create playlist"
msgstr "Impossible de créer la collection"
#: src/pages/UserPublicProfile.tsx:65
#: src/pages/UserPublicProfile.tsx:68
#: src/pages/UserPublicProfile.tsx:96
#: src/pages/UserPublicProfile.tsx:72
#: src/pages/UserPublicProfile.tsx:75
#: src/pages/UserPublicProfile.tsx:103
msgid "Failed to generate invite"
msgstr "Impossible de générer une invitation"
@@ -369,13 +417,13 @@ msgstr "Impossible de générer une invitation"
#: src/pages/index/JournalFeed.tsx:48
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:323
#: src/pages/UserPublicProfile.tsx:1081
#: src/pages/UserPublicProfile.tsx:1118
#: src/pages/UserPublicProfile.tsx:1160
#: src/pages/UserPublicProfile.tsx:1106
#: src/pages/UserPublicProfile.tsx:1148
#: src/pages/UserPublicProfile.tsx:1193
msgid "Failed to load"
msgstr "Chargement échoué"
#: src/components/DumpCreateModal.tsx:322
#: src/components/DumpCreateModal.tsx:315
msgid "Failed to post"
msgstr "Publication échouée"
@@ -388,10 +436,10 @@ msgid "Failed to post reply"
msgstr "Impossible de publier la réponse"
#: src/pages/PlaylistDetail.tsx:789
#: src/pages/UserPublicProfile.tsx:663
#: src/pages/UserPublicProfile.tsx:701
#: src/pages/UserPublicProfile.tsx:828
#: src/pages/UserPublicProfile.tsx:905
#: src/pages/UserPublicProfile.tsx:680
#: src/pages/UserPublicProfile.tsx:718
#: src/pages/UserPublicProfile.tsx:845
#: src/pages/UserPublicProfile.tsx:922
msgid "Failed to save"
msgstr "Enregistrement échoué"
@@ -399,24 +447,24 @@ msgstr "Enregistrement échoué"
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
#: src/pages/UserPublicProfile.tsx:851
#: src/pages/UserPublicProfile.tsx:868
msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar"
#: src/components/DumpCreateModal.tsx:359
#: src/components/DumpCreateModal.tsx:352
msgid "Fetching preview…"
msgstr "Récupération de l'aperçu…"
#: src/components/DumpCreateModal.tsx:432
#: src/components/DumpCreateModal.tsx:425
msgid "Fetching…"
msgstr "Récupération…"
#: src/components/DumpCreateModal.tsx:315
#: src/components/DumpCreateModal.tsx:308
#: src/components/FileDropZone.tsx:31
msgid "File"
msgstr "Fichier"
#: src/components/DumpCreateModal.tsx:209
#: src/components/DumpCreateModal.tsx:202
msgid "File too large (max 50 MB)."
msgstr "Fichier trop volumineux (max 50 Mo)."
@@ -442,7 +490,7 @@ msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/FeedTabBar.tsx:47
#: src/pages/UserPublicProfile.tsx:964
#: src/pages/UserPublicProfile.tsx:981
msgid "Followed"
msgstr "Suivi"
@@ -452,16 +500,20 @@ msgstr "Suivi"
msgid "Followed ({0}{1})"
msgstr "Suivies ({0}{1})"
#: src/pages/UserPublicProfile.tsx:1109
#: src/pages/UserPublicProfile.tsx:1137
msgid "Followed playlists"
msgstr "Collections suivies"
#: src/components/FollowButton.tsx:37
#: src/components/FollowButton.tsx:64
#: src/pages/UserPublicProfile.tsx:1072
#: src/pages/UserPublicProfile.tsx:1095
msgid "Following"
msgstr "Abonné"
#: src/pages/UserLogin.tsx:131
msgid "Forgot password?"
msgstr "Mot de passe oublié ?"
#: src/pages/index/FollowedFeed.tsx:337
msgid "From people"
msgstr "De personnes"
@@ -470,20 +522,32 @@ msgstr "De personnes"
msgid "From playlists"
msgstr "De collections"
#: src/pages/ResetPassword.tsx:56
msgid "Go to login"
msgstr "Aller à la connexion"
#: src/components/FeedTabBar.tsx:25
msgid "Hot"
msgstr "Tendances"
#: src/pages/UserLogin.tsx:140
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
#: src/pages/UserRegister.tsx:106
msgid "Invalid invite"
msgstr "Invitation invalide"
#: src/pages/UserPublicProfile.tsx:773
#: src/pages/ResetPassword.tsx:33
msgid "Invalid link"
msgstr "Lien invalide"
#: src/pages/UserPublicProfile.tsx:790
msgid "invited by"
msgstr "invité par"
#: src/pages/UserPublicProfile.tsx:971
#: src/pages/UserPublicProfile.tsx:1149
#: src/pages/UserPublicProfile.tsx:988
#: src/pages/UserPublicProfile.tsx:1182
msgid "Invitees"
msgstr "Invités"
@@ -495,7 +559,7 @@ msgstr "Journal"
msgid "just now"
msgstr "à l'instant"
#: src/pages/UserPublicProfile.tsx:1227
#: src/pages/UserPublicProfile.tsx:1275
msgid "Light"
msgstr "Clair"
@@ -532,7 +596,7 @@ msgstr "Chargement…"
msgid "Loading playlist…"
msgstr "Chargement de la collection…"
#: src/pages/UserPublicProfile.tsx:711
#: src/pages/UserPublicProfile.tsx:728
msgid "Loading profile…"
msgstr "Chargement du profil…"
@@ -546,29 +610,29 @@ msgstr "Chargement du profil…"
#: src/pages/Notifications.tsx:395
#: src/pages/UserDumps.tsx:51
#: src/pages/UserPlaylists.tsx:342
#: src/pages/UserPublicProfile.tsx:1077
#: src/pages/UserPublicProfile.tsx:1114
#: src/pages/UserPublicProfile.tsx:1154
#: src/pages/UserPublicProfile.tsx:1100
#: src/pages/UserPublicProfile.tsx:1142
#: src/pages/UserPublicProfile.tsx:1187
#: src/pages/UserUpvoted.tsx:123
msgid "Loading…"
msgstr "Chargement…"
#: src/components/AppHeader.tsx:74
#: src/pages/UserLogin.tsx:63
#: src/pages/UserLogin.tsx:93
#: src/pages/UserLogin.tsx:87
#: src/pages/UserLogin.tsx:117
msgid "Log in"
msgstr "Se connecter"
#: src/pages/UserPublicProfile.tsx:732
#: src/pages/UserPublicProfile.tsx:865
#: src/pages/UserPublicProfile.tsx:749
#: src/pages/UserPublicProfile.tsx:882
msgid "Log out"
msgstr "Se déconnecter"
#: src/pages/UserLogin.tsx:92
#: src/pages/UserLogin.tsx:116
msgid "Logging in…"
msgstr "Connexion…"
#: src/pages/UserLogin.tsx:67
#: src/pages/UserLogin.tsx:91
msgid "Login failed"
msgstr "Connexion échouée"
@@ -584,10 +648,15 @@ msgstr "nouveau"
msgid "New"
msgstr "Nouveau"
#: src/components/DumpCreateModal.tsx:284
#: src/components/DumpCreateModal.tsx:277
msgid "New dump"
msgstr "Nouvelle reco"
#: src/components/ChangePasswordModal.tsx:88
#: src/pages/ResetPassword.tsx:101
msgid "New password"
msgstr "Nouveau mot de passe"
#: src/components/NewPlaylistForm.tsx:34
msgid "New playlist"
msgstr "Nouvelle collection"
@@ -611,11 +680,11 @@ msgid "No emoji found."
msgstr "Aucun emoji trouvé."
#: src/pages/UserPlaylists.tsx:439
#: src/pages/UserPublicProfile.tsx:1122
#: src/pages/UserPublicProfile.tsx:1155
msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies."
#: src/pages/UserPublicProfile.tsx:1167
#: src/pages/UserPublicProfile.tsx:1200
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
@@ -625,7 +694,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/PlaylistMembershipPanel.tsx:34
#: src/pages/UserPlaylists.tsx:397
#: src/pages/UserPublicProfile.tsx:1043
#: src/pages/UserPublicProfile.tsx:1066
msgid "No playlists yet."
msgstr "Pas encore de collections."
@@ -633,14 +702,14 @@ msgstr "Pas encore de collections."
msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »."
#: src/pages/UserPublicProfile.tsx:1085
#: src/pages/UserPublicProfile.tsx:1113
msgid "Not following anyone yet."
msgstr "Aucun abonnement pour le moment."
#: src/pages/Notifications.tsx:330
#: src/pages/UserDumps.tsx:123
#: src/pages/UserPublicProfile.tsx:1292
#: src/pages/UserPublicProfile.tsx:1415
#: src/pages/UserPublicProfile.tsx:1340
#: src/pages/UserPublicProfile.tsx:1463
#: src/pages/UserUpvoted.tsx:195
msgid "Nothing here yet."
msgstr "Rien ici pour l'instant."
@@ -662,7 +731,8 @@ msgstr "Ouvrir la recherche"
msgid "or <0>browse files</0>"
msgstr "ou <0>parcourir les fichiers</0>"
#: src/pages/UserLogin.tsx:82
#: src/pages/UserLogin.tsx:106
#: src/pages/UserPublicProfile.tsx:1220
msgid "Password"
msgstr "Mot de passe"
@@ -671,21 +741,34 @@ msgstr "Mot de passe"
msgid "Password (min. {0} characters)"
msgstr "Mot de passe (min. {0} caractères)"
#: src/components/ChangePasswordModal.tsx:60
msgid "Password changed successfully."
msgstr "Mot de passe modifié avec succès."
#: src/pages/ResetPassword.tsx:47
msgid "Password updated"
msgstr "Mot de passe mis à jour"
#: src/components/ChangePasswordModal.tsx:118
#: src/pages/ResetPassword.tsx:129
msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas"
#: src/components/AppHeader.tsx:50
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:175
#: src/pages/UserPlaylists.tsx:368
#: src/pages/UserPublicProfile.tsx:957
#: src/pages/UserPublicProfile.tsx:974
msgid "Playlists"
msgstr "Collections"
#. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:1016
#: src/pages/UserPublicProfile.tsx:1035
msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})"
#: src/components/DumpCreateModal.tsx:202
#: src/components/DumpCreateModal.tsx:195
msgid "Please select a file."
msgstr "Veuillez sélectionner un fichier."
@@ -710,7 +793,7 @@ msgstr "Publication…"
msgid "private"
msgstr "privé"
#: src/components/DumpCreateModal.tsx:411
#: src/components/DumpCreateModal.tsx:404
#: src/components/PlaylistCreateForm.tsx:99
#: src/pages/DumpEdit.tsx:285
#: src/pages/PlaylistDetail.tsx:746
@@ -722,7 +805,7 @@ msgstr "Privé"
msgid "public"
msgstr "public"
#: src/components/DumpCreateModal.tsx:403
#: src/components/DumpCreateModal.tsx:396
#: src/components/PlaylistCreateForm.tsx:92
#: src/pages/DumpEdit.tsx:278
#: src/pages/PlaylistDetail.tsx:739
@@ -766,6 +849,14 @@ msgstr "Remplacer le fichier"
msgid "Reply"
msgstr "Répondre"
#: src/pages/UserLogin.tsx:150
msgid "Request failed"
msgstr "Échec de la demande"
#: src/pages/ResetPassword.tsx:94
msgid "Reset failed"
msgstr "Échec de la réinitialisation"
#: src/pages/Dump.tsx:211
#: src/pages/DumpEdit.tsx:163
msgid "Retry"
@@ -774,15 +865,17 @@ msgstr "Réessayer"
#: src/components/CommentThread.tsx:270
#: src/pages/DumpEdit.tsx:306
#: src/pages/PlaylistDetail.tsx:673
#: src/pages/UserPublicProfile.tsx:816
#: src/pages/UserPublicProfile.tsx:894
#: src/pages/UserPublicProfile.tsx:833
#: src/pages/UserPublicProfile.tsx:911
msgid "Save"
msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:141
#: src/components/CommentThread.tsx:269
#: src/pages/PlaylistDetail.tsx:673
#: src/pages/UserPublicProfile.tsx:815
#: src/pages/UserPublicProfile.tsx:894
#: src/pages/ResetPassword.tsx:140
#: src/pages/UserPublicProfile.tsx:832
#: src/pages/UserPublicProfile.tsx:911
msgid "Saving…"
msgstr "Enregistrement…"
@@ -802,11 +895,24 @@ msgstr "Recherche échouée"
msgid "Searching…"
msgstr "Recherche…"
#: src/pages/UserLogin.tsx:175
msgid "Send reset link"
msgstr "Envoyer le lien de réinitialisation"
#: src/pages/UserLogin.tsx:174
msgid "Sending…"
msgstr "Envoi…"
#: src/components/AppHeader.tsx:65
msgid "Server unreachable"
msgstr "Serveur inaccessible"
#: src/pages/UserPublicProfile.tsx:979
#: src/pages/ResetPassword.tsx:91
#: src/pages/ResetPassword.tsx:141
msgid "Set new password"
msgstr "Définir un nouveau mot de passe"
#: src/pages/UserPublicProfile.tsx:996
msgid "Settings"
msgstr "Paramètres"
@@ -814,7 +920,7 @@ msgstr "Paramètres"
msgid "Something went wrong"
msgstr "Une erreur est survenue"
#: src/pages/UserPublicProfile.tsx:1191
#: src/pages/UserPublicProfile.tsx:1239
msgid "Style"
msgstr "Style"
@@ -822,11 +928,11 @@ msgstr "Style"
msgid "Submit search"
msgstr "Lancer la recherche"
#: src/pages/UserPublicProfile.tsx:882
#: src/pages/UserPublicProfile.tsx:899
msgid "Tell people about yourself…"
msgstr "Parlez de vous…"
#: src/components/DumpCreateModal.tsx:390
#: src/components/DumpCreateModal.tsx:383
#: src/pages/DumpEdit.tsx:266
msgid "Tell the community what makes this worth their time..."
msgstr "Dites à la communauté pourquoi ça vaut le coup…"
@@ -835,10 +941,14 @@ msgstr "Dites à la communauté pourquoi ça vaut le coup…"
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:98
#: src/pages/UserLogin.tsx:184
msgid "This is a mirage."
msgstr "C'est un mirage."
#: src/pages/ResetPassword.tsx:34
msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé."
#: src/components/PlaylistCreateForm.tsx:72
msgid "Title"
msgstr "Titre"
@@ -859,11 +969,16 @@ msgstr "Ne plus suivre {targetUsername}"
msgid "Unfollow playlist"
msgstr "Ne plus suivre la collection"
#: src/pages/UserPublicProfile.tsx:632
#: src/components/ChangePasswordModal.tsx:43
#: src/pages/ResetPassword.tsx:80
msgid "Unknown error"
msgstr "Erreur inconnue"
#: src/pages/UserPublicProfile.tsx:649
msgid "Upload failed"
msgstr "Envoi échoué"
#: src/components/DumpCreateModal.tsx:433
#: src/components/DumpCreateModal.tsx:426
msgid "Uploading…"
msgstr "Envoi…"
@@ -873,16 +988,16 @@ msgstr "Voté"
#. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:998
#: src/pages/UserPublicProfile.tsx:1015
msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})"
#: src/components/DumpCreateModal.tsx:332
#: src/components/DumpCreateModal.tsx:325
#: src/pages/DumpEdit.tsx:230
msgid "URL"
msgstr "URL"
#: src/components/DumpCreateModal.tsx:185
#: src/components/DumpCreateModal.tsx:178
msgid "URL is required."
msgstr "L'URL est obligatoire."
@@ -890,7 +1005,7 @@ msgstr "L'URL est obligatoire."
msgid "User menu"
msgstr "Menu utilisateur"
#: src/pages/UserLogin.tsx:74
#: src/pages/UserLogin.tsx:98
#: src/pages/UserRegister.tsx:129
msgid "Username"
msgstr "Nom d'utilisateur"
@@ -899,19 +1014,19 @@ msgstr "Nom d'utilisateur"
msgid "Users"
msgstr "Utilisateurs"
#: src/pages/UserPublicProfile.tsx:1062
#: src/pages/UserPublicProfile.tsx:1100
#: src/pages/UserPublicProfile.tsx:1137
#: src/pages/UserPublicProfile.tsx:1313
#: src/pages/UserPublicProfile.tsx:1445
#: src/pages/UserPublicProfile.tsx:1085
#: src/pages/UserPublicProfile.tsx:1128
#: src/pages/UserPublicProfile.tsx:1170
#: src/pages/UserPublicProfile.tsx:1361
#: src/pages/UserPublicProfile.tsx:1493
msgid "View all →"
msgstr "Tout voir →"
#: src/components/DumpCreateModal.tsx:447
#: src/components/DumpCreateModal.tsx:440
msgid "View dump →"
msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:383
#: src/components/DumpCreateModal.tsx:376
#: src/pages/DumpEdit.tsx:260
msgid "Why are you dumping this?"
msgstr "Pourquoi recommandez-vous ça ?"
@@ -939,3 +1054,11 @@ msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vo
#: src/pages/UserUpvoted.tsx:182
msgid "You've reached the end."
msgstr "Vous avez tout lu, tout vu, tout bu."
#: src/pages/UserLogin.tsx:160
msgid "Your email address"
msgstr "Votre adresse e-mail"
#: src/pages/ResetPassword.tsx:49
msgid "Your password has been changed. You can now log in."
msgstr "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."

165
src/pages/ResetPassword.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
type State =
| { status: "idle" }
| { status: "submitting" }
| { status: "done" }
| { status: "error"; error: string };
export function ResetPassword() {
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get("token") ?? "";
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [state, setState] = useState<State>({ status: "idle" });
const mismatch = confirm.length > 0 && newPassword !== confirm;
const tooShort = newPassword.length > 0 &&
newPassword.length < VALIDATION.PASSWORD_MIN;
if (!token) {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Invalid link</Trans>
</h1>
<p>
<Trans>This reset link is missing or malformed.</Trans>
</p>
<Link
to="/login"
className="btn-primary"
style={{ marginTop: "1rem", display: "inline-block" }}
>
<Trans>Back to login</Trans>
</Link>
</div>
</PageShell>
);
}
if (state.status === "done") {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Password updated</Trans>
</h1>
<p style={{ marginBottom: "1rem" }}>
<Trans>Your password has been changed. You can now log in.</Trans>
</p>
<button
type="button"
className="btn-primary"
onClick={() => navigate("/login")}
>
<Trans>Go to login</Trans>
</button>
</div>
</PageShell>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (mismatch || tooShort || !newPassword) return;
setState({ status: "submitting" });
try {
const res = await fetch(`${API_URL}/api/users/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword }),
});
const body = await res.json();
if (body.success) {
setState({ status: "done" });
} else {
setState({
status: "error",
error: body.error?.message ?? t`Unknown error`,
});
}
} catch {
setState({ status: "error", error: t`Could not connect to server` });
}
};
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Set new password</Trans>
</h1>
{state.status === "error" && (
<ErrorCard title={t`Reset failed`} message={state.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<input
type="password"
placeholder={t`New password`}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
required
autoFocus
disabled={state.status === "submitting"}
/>
{tooShort && (
<span className="auth-field-hint auth-field-hint--error">
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
</span>
)}
</div>
<div className="form-group">
<input
type="password"
placeholder={t`Confirm new password`}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
disabled={state.status === "submitting"}
/>
{mismatch && (
<span className="auth-field-hint auth-field-hint--error">
<Trans>Passwords do not match</Trans>
</span>
)}
</div>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting" || mismatch || tooShort ||
!newPassword || !confirm}
>
{state.status === "submitting"
? <Trans>Saving</Trans>
: <Trans>Set new password</Trans>}
</button>
</form>
<p className="auth-card-footer">
<Link to="/login">
<Trans>Back to login</Trans>
</Link>
</p>
</div>
</PageShell>
);
}

View File

@@ -16,21 +16,29 @@ import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type UserLoginState =
type LoginState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
type ResetState =
| { status: "idle" }
| { status: "submitting" }
| { status: "sent" }
| { status: "error"; error: string };
export function UserLogin() {
const navigate = useNavigate();
const { login } = useAuth();
const [state, setState] = useState<UserLoginState>({ status: "idle" });
const [loginState, setLoginState] = useState<LoginState>({ status: "idle" });
const [showReset, setShowReset] = useState(false);
const [resetEmail, setResetEmail] = useState("");
const [resetState, setResetState] = useState<ResetState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
setLoginState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username") as string;
@@ -49,10 +57,26 @@ export function UserLogin() {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setState({ status: "error", error: apiResponse.error.message });
setLoginState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });
setLoginState({ status: "error", error: friendlyFetchError(err) });
}
};
const handleResetRequest = async (e: React.FormEvent) => {
e.preventDefault();
if (!resetEmail.trim()) return;
setResetState({ status: "submitting" });
try {
await fetch(`${API_URL}/api/users/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: resetEmail.trim() }),
});
setResetState({ status: "sent" });
} catch {
setResetState({ status: "error", error: t`Could not connect to server` });
}
};
@@ -63,8 +87,8 @@ export function UserLogin() {
<Trans>Log in</Trans>
</h1>
{state.status === "error" && (
<ErrorCard title={t`Login failed`} message={state.error} />
{loginState.status === "error" && (
<ErrorCard title={t`Login failed`} message={loginState.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
@@ -73,7 +97,7 @@ export function UserLogin() {
type="text"
placeholder={t`Username`}
required
disabled={state.status === "submitting"}
disabled={loginState.status === "submitting"}
autoFocus
/>
<input
@@ -81,19 +105,81 @@ export function UserLogin() {
type="password"
placeholder={t`Password`}
required
disabled={state.status === "submitting"}
disabled={loginState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting"}
disabled={loginState.status === "submitting"}
>
{state.status === "submitting"
{loginState.status === "submitting"
? <Trans>Logging in</Trans>
: <Trans>Log in</Trans>}
</button>
</form>
<p className="auth-card-footer">
<button
type="button"
className="auth-link-btn"
onClick={() => {
setShowReset((v) => !v);
setResetState({ status: "idle" });
setResetEmail("");
}}
>
<Trans>Forgot password?</Trans>
</button>
</p>
{showReset && (
<div className="auth-reset-panel">
{resetState.status === "sent"
? (
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link
shortly.
</Trans>
</p>
)
: (
<>
{resetState.status === "error" && (
<ErrorCard
title={t`Request failed`}
message={resetState.error}
/>
)}
<form
onSubmit={handleResetRequest}
className="auth-form auth-reset-form"
>
<input
type="email"
placeholder={t`Your email address`}
value={resetEmail}
onChange={(e) => setResetEmail(e.target.value)}
required
autoFocus
disabled={resetState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={resetState.status === "submitting" ||
!resetEmail.trim()}
>
{resetState.status === "submitting"
? <Trans>Sending</Trans>
: <Trans>Send reset link</Trans>}
</button>
</form>
</>
)}
</div>
)}
<p className="auth-card-footer">
<Trans>This is a mirage.</Trans>
</p>

View File

@@ -52,6 +52,7 @@ import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { TextEditor } from "../components/TextEditor.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
function InviteButton() {
const { authFetch } = useAuth();
@@ -284,6 +285,7 @@ export function UserPublicProfile() {
const [tab, setTab] = useState<
"dumps" | "playlists" | "followed" | "invitees" | "settings"
>("dumps");
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const [followedState, setFollowedState] = useState<FollowedState>(null);
const [inviteTreeState, setInviteTreeState] = useState<InviteTreeState>(null);
@@ -1202,8 +1204,31 @@ export function UserPublicProfile() {
</section>
)}
{changePasswordOpen && (
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
)}
{tab === "settings" && isOwnProfile && (
<>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Account</Trans>
</h2>
<div className="profile-appearance-grid">
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Password</Trans>
</span>
<button
type="button"
className="btn btn--ghost"
onClick={() => setChangePasswordOpen(true)}
>
<Trans>Change password</Trans>
</button>
</div>
</div>
</section>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Appearance</Trans>

View File

@@ -1,3 +1,10 @@
export function normalizeUrl(input: string): string {
const s = input.trim();
if (!s || /^https?:\/\//i.test(s)) return s;
if (s.startsWith("//")) return `https:${s}`;
return `https://${s}`;
}
export function dumpUrl(dump: { id: string; slug?: string }): string {
return `/dumps/${dump.slug ?? dump.id}`;
}