diff --git a/.env.example b/.env.example index dc3f3ae..8bc17d8 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index b82c533..9dad5e6 100644 --- a/README.md +++ b/README.md @@ -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) ``` diff --git a/api/config.ts b/api/config.ts index 8e82a00..ed32b91 100644 --- a/api/config.ts +++ b/api/config.ts @@ -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 diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 84c8ba2..aa24e0e 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -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 { + 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 { + 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, ): Promise { diff --git a/api/model/db.ts b/api/model/db.ts index 5bff035..960b42c 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -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 { diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 818e77e..43b1f9d 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -177,6 +177,22 @@ export function isInvitePayload(obj: unknown): obj is InvitePayload { typeof (obj as Record).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).purpose === "password-reset" && + "userId" in obj && + typeof (obj as Record).userId === "string"; +} + /** * API */ diff --git a/api/routes/users.ts b/api/routes/users.ts index 090347d..ed4bfda 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -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; + 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(); diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts new file mode 100644 index 0000000..89b5dfa --- /dev/null +++ b/api/services/password-reset-service.ts @@ -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 { + 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 { + 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); +} diff --git a/api/services/user-service.ts b/api/services/user-service.ts index b149b3d..da4b39b 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -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, diff --git a/api/sql/schema.sql b/api/sql/schema.sql index 69db838..dc16fff 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -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, diff --git a/deno.json b/deno.json index 342f044..02195ce 100644 --- a/deno.json +++ b/deno.json @@ -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": { diff --git a/scripts/lingui-compile.ts b/scripts/lingui-compile.ts new file mode 100644 index 0000000..56912bf --- /dev/null +++ b/scripts/lingui-compile.ts @@ -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 } }); diff --git a/src/App.css b/src/App.css index 2ce29d6..0133b02 100644 --- a/src/App.css +++ b/src/App.css @@ -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 { diff --git a/src/App.tsx b/src/App.tsx index dc05984..9b23145 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { /> } /> } /> + } /> 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(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 ( + + {done + ? ( +
+

+ Password changed successfully. +

+
+
+ +
+
+
+ ) + : ( +
+ + + + {error && ( + + )} +
+
+ + +
+
+ + )} +
+ ); +} diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 1c42de8..8a3ca56 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -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"; diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index ee924a2..c22b0ab 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -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 - ? ( - - - - - ) - : ( - - - - )} + {isPlaying ? : } {peaks ? ( diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 7efa1b7..2d1b687 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -15,13 +15,13 @@ function fmt(s: number): string { return `${m}:${sec.toString().padStart(2, "0")}`; } -const IconPlay = () => ( +export const IconPlay = () => ( ); -const IconPause = () => ( +export const IconPause = () => ( diff --git a/src/locales/en.js b/src/locales/en.js index cdd1011..ae86e37 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -1,5 +1,5 @@ /*eslint-disable*/ module.exports = { messages: JSON.parse( - '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"0kWhlg":["File too large (max 5 MB)"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2Hlmdt":["Write a reply…"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3T1cI4":["Unexpected server error"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4RtQ1k":["Unfollow ",["targetUsername"]],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6gRgw8":["Retry"],"7JBW66":["Forbidden"],"7PHCIN":["Cancel removal"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8ZsakT":["Password"],"8pxhI8":["Please select a file."],"9l4qcT":["Drop a replacement here"],"9uI_rE":["Undo"],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AQbgNR":["Follow ",["targetUsername"]],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade-6d":["Live updates unavailable."],"CI50ct":["Upvoted"],"Cj24wt":["Registering…"],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"]," upvoted <1>",["1"],""],"ECiS12":["View dump →"],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GNSsCc":["Failed to create playlist"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GsRMX3":["Playlist not found"],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"ImOQa9":["Reply"],"J2eKUI":["File"],"Jd58Fo":["Hot"],"K1JdNl":["Username already exists"],"KDGWg5":["Remove from playlist"],"K_F6pa":["Saving…"],"LLyMkV":["Followed (",["0"],["1"],")"],"LPAv9E":[["days"],"d ago"],"Lld1jm":["Tell people about yourself…"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"Mq2B8E":[["mins"],"m ago"],"NR0xa9":["Tell the community what makes this worth their time..."],"Nn4kr3":["+ New dump"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"PiH3UR":["Copied!"],"Pwqkdw":["Loading…"],"Q6n4F4":["Refresh metadata"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"SBTElJ":["Searching…"],"Sxm8rQ":["Users"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TM1ZbA":["Playlists (",["0"],["1"],")"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"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"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"alBtu4":["Invalid or expired invite"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"cILfnJ":["Remove file"],"cYP9Sb":["+ Playlist"],"cnGeoo":["Delete"],"d8DZWS":["Open search"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dSKHAa":["Invalid username or password"],"dTU6Wi":["Password must be at most 128 characters"],"dbc28f":["Why are you dumping this?"],"eFSqvc":["Failed to post reply"],"ePK91l":["Edit"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"en9o7K":["Failed to post comment"],"fGxPOv":["Add a bio…"],"fI-mNw":["Playlists"],"f_akpP":["Max 50 MB"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hYgDIe":["Create"],"he3ygx":["Copy"],"iDNBZe":["Notifications"],"ijVyoK":["Could not reach the server. Please try again."],"isRobC":["New"],"jGrTH0":["Not authenticated"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"kLttbL":["Registration failed"],"kYYCil":["File too large (max 50 MB)"],"l3JaOO":["Cannot edit a deleted comment"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"pvnfJD":["Dark"],"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"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"u4pkXs":["Invite already used"],"uD0qXQ":["Drop a file here"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vuosjb":["User menu"],"vwGkYB":["Password must be at least 8 characters"],"wXO4Tg":[["hrs"],"h ago"],"wbXKOv":["File too large (max 50 MB)."],"wdiqRH":["Admin access required"],"wixIgH":["Already have an account? <0>Log in"],"x4aBfU":["Dump not found"],"xEWkgZ":["← Back to all dumps"],"xOTzt5":["just now"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', + '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"0kWhlg":["File too large (max 5 MB)"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"29VNqC":["Unknown error"],"2Hlmdt":["Write a reply…"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3T1cI4":["Unexpected server error"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"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."],"AeXO77":["Account"],"CI50ct":["Upvoted"],"Cj24wt":["Registering…"],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"]," upvoted <1>",["1"],""],"ECiS12":["View dump →"],"ExR0Fr":["URL is required."],"F1O9Ep":["Could not connect to server"],"F5Js1v":["Unfollow playlist"],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GNSsCc":["Failed to create playlist"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"GsRMX3":["Playlist not found"],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"I-x669":["Invitees"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"ImOQa9":["Reply"],"J2eKUI":["File"],"JJ-Bhk":["Your email address"],"JRQitQ":["Confirm new password"],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"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!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q6n4F4":["Refresh metadata"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"SBTElJ":["Searching…"],"Sad2tK":["Sending…"],"Sxm8rQ":["Users"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TM1ZbA":["Playlists (",["0"],["1"],")"],"TN382O":["Invalid link"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U7u3q-":["+ New"],"UNMVei":["Forgot password?"],"UOZith":["Failed to post"],"UTiUFs":["Fetching…"],"VCoEm-":["Back to login"],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VyTYmS":["Change avatar"],"WhimMi":["Reset failed"],"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"],"XgRtUf":["Could not change password"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YaSA2K":["Comment not found"],"YpkCca":["No followed playlists yet."],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZmD2o6":["Create & Add"],"_3O5R_":["Request failed"],"_84wxb":["All ",["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]," loaded."],"_DwR-n":["Creating…"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"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"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"d8DZWS":["Open search"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dSKHAa":["Invalid username or password"],"dTU6Wi":["Password must be at most 128 characters"],"dbc28f":["Why are you dumping this?"],"eFSqvc":["Failed to post reply"],"ePK91l":["Edit"],"eaUTwS":["Send reset link"],"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."],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hYgDIe":["Create"],"he3ygx":["Copy"],"iDNBZe":["Notifications"],"ijVyoK":["Could not reach the server. Please try again."],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"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)"],"klOeIX":["Failed to change password"],"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?"],"lpIMne":["Passwords do not match"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"pSheLH":["No invitees yet."],"pvnfJD":["Dark"],"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"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"u4pkXs":["Invite already used"],"uD0qXQ":["Drop a file here"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vuosjb":["User menu"],"vwGkYB":["Password must be at least 8 characters"],"wXO4Tg":[["hrs"],"h ago"],"wbXKOv":["File too large (max 50 MB)."],"wdiqRH":["Admin access required"],"wixIgH":["Already have an account? <0>Log in"],"x4aBfU":["Dump not found"],"xEWkgZ":["← Back to all dumps"],"xOTzt5":["just now"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', ), }; diff --git a/src/locales/en.po b/src/locales/en.po index 0600e21..d8aa39c 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -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" msgstr "Already have an account? <0>Log in" -#: 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" msgstr "or <0>browse files" -#: 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." diff --git a/src/locales/fr.js b/src/locales/fr.js index 09dd0bd..bd608d2 100644 --- a/src/locales/fr.js +++ b/src/locales/fr.js @@ -1,5 +1,5 @@ /*eslint-disable*/ module.exports = { messages: JSON.parse( - '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"0kWhlg":["File too large (max 5 MB)"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2Hlmdt":["Écrire une réponse…"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3T1cI4":["Unexpected server error"],"3yfh3D":["<0>",["0"]," a suivi votre collection <1>",["1"],""],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6gRgw8":["Réessayer"],"7JBW66":["Forbidden"],"7PHCIN":["Annuler la suppression"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8ZsakT":["Mot de passe"],"8pxhI8":["Veuillez sélectionner un fichier."],"9l4qcT":["Déposez un fichier de remplacement ici"],"9uI_rE":["Annuler"],"A0y396":["+ Inviter quelqu\'un"],"A1taO8":["Rechercher"],"AQbgNR":["Suivre ",["targetUsername"]],"ATGYL1":["Adresse e-mail"],"AZctoV":[["0","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"Ade-6d":["Mises à jour en direct indisponibles."],"CI50ct":["Voté"],"Cj24wt":["Inscription…"],"DPfwMq":["Terminé"],"DdeHXH":["Supprimer cette reco ? Cette action est irréversible."],"Dp1JhP":["<0>",["0"]," a voté pour <1>",["1"],""],"ECiS12":["Voir la reco →"],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GNSsCc":["Impossible de créer la collection"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GsRMX3":["Playlist not found"],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"ImOQa9":["Répondre"],"J2eKUI":["Fichier"],"Jd58Fo":["Tendances"],"K1JdNl":["Username already exists"],"KDGWg5":["Retirer de la collection"],"K_F6pa":["Enregistrement…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"LPAv9E":["il y a ",["days"],"j"],"Lld1jm":["Parlez de vous…"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"Mq2B8E":["il y a ",["mins"],"min"],"NR0xa9":["Dites à la communauté pourquoi ça vaut le coup…"],"Nn4kr3":["+ Nouvelle reco"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"PiH3UR":["Copié !"],"Pwqkdw":["Chargement…"],"Q6n4F4":["Actualiser les métadonnées"],"QKsaQr":["ou <0>parcourir les fichiers"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"]," a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"SBTElJ":["Recherche…"],"Sxm8rQ":["Utilisateurs"],"T9bjWt":["<0>",["0"]," a été ajouté à <1>",["1"],""],"TM1ZbA":["Collections (",["0"],["1"],")"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U7u3q-":["+ Nouveau"],"UOZith":["Publication échouée"],"UTiUFs":["Récupération…"],"VnNJbN":["De collections"],"VyTYmS":["Changer l\'avatar"],"WpXcBJ":["Rien ici pour l\'instant."],"WtkMN8":["Toutes les ",["0","plural",{"one":["#"," reco votée"],"other":["#"," recos votées"]}]," chargées."],"XILg0L":["Invalid email address"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"YK1Dhc":["une publication"],"YaSA2K":["Comment not found"],"YpkCca":["Pas encore de collections suivies."],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZmD2o6":["Créer et ajouter"],"_84wxb":["Toutes les ",["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]," chargées."],"_DwR-n":["Création…"],"_aept4":["Publier la réponse"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"alBtu4":["Invalid or expired invite"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"cILfnJ":["Supprimer le fichier"],"cYP9Sb":["+ Collection"],"cnGeoo":["Supprimer"],"d8DZWS":["Ouvrir la recherche"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dSKHAa":["Invalid username or password"],"dTU6Wi":["Password must be at most 128 characters"],"dbc28f":["Pourquoi recommandez-vous ça ?"],"eFSqvc":["Impossible de publier la réponse"],"ePK91l":["Modifier"],"ecUA8p":["Aujourd\'hui"],"ef9nPf":["Chargement de la reco…"],"en9o7K":["Impossible de publier le commentaire"],"fGxPOv":["Ajouter une bio…"],"fI-mNw":["Collections"],"f_akpP":["Max 50 Mo"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"]," a publié <1>",["1"],""],"hYgDIe":["Créer"],"he3ygx":["Copier"],"iDNBZe":["Notifications"],"ijVyoK":["Impossible de contacter le serveur. Veuillez réessayer."],"isRobC":["Nouveau"],"jGrTH0":["Not authenticated"],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"kLttbL":["Inscription échouée"],"kYYCil":["File too large (max 50 MB)"],"l3JaOO":["Cannot edit a deleted comment"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"pCpd9p":["<0>",["0"]," vous a mentionné dans <1>",["where"],""],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qvFa8r":["public"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rg9pXu":["Recherche échouée"],"rtpJqV":["Recos (",["0"],["1"],")"],"sBZMWb":["Invalid URL"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"shHs8T":["Saisissez une recherche."],"siMTjB":["File content is not a recognised image (JPEG, PNG, GIF, WebP)"],"smeBfS":["Invitation invalide"],"tfDRzk":["Enregistrer"],"tqKwXl":["Username must be 1–32 characters and contain only letters, numbers, or underscores"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"u4pkXs":["Invite already used"],"uD0qXQ":["Déposez un fichier ici"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vuosjb":["Menu utilisateur"],"vwGkYB":["Password must be at least 8 characters"],"wXO4Tg":["il y a ",["hrs"],"h"],"wbXKOv":["Fichier trop volumineux (max 50 Mo)."],"wdiqRH":["Admin access required"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter"],"x4aBfU":["Dump not found"],"xEWkgZ":["← Retour à toutes les recos"],"xOTzt5":["à l\'instant"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}', + '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"0kWhlg":["File too large (max 5 MB)"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"29VNqC":["Erreur inconnue"],"2Hlmdt":["Écrire une réponse…"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3T1cI4":["Unexpected server error"],"3yfh3D":["<0>",["0"]," a suivi votre collection <1>",["1"],""],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6gRgw8":["Réessayer"],"7JBW66":["Forbidden"],"7PHCIN":["Annuler la suppression"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8ZsakT":["Mot de passe"],"8pxhI8":["Veuillez sélectionner un fichier."],"9l4qcT":["Déposez un fichier de remplacement ici"],"9uI_rE":["Annuler"],"A0y396":["+ Inviter quelqu\'un"],"A1taO8":["Rechercher"],"AQbgNR":["Suivre ",["targetUsername"]],"ATGYL1":["Adresse e-mail"],"AZctoV":[["0","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"Ade-6d":["Mises à jour en direct indisponibles."],"AeXO77":["Compte"],"CI50ct":["Voté"],"Cj24wt":["Inscription…"],"DPfwMq":["Terminé"],"DdeHXH":["Supprimer cette reco ? Cette action est irréversible."],"Dp1JhP":["<0>",["0"]," a voté pour <1>",["1"],""],"ECiS12":["Voir la reco →"],"ExR0Fr":["L\'URL est obligatoire."],"F1O9Ep":["Impossible de contacter le serveur"],"F5Js1v":["Ne plus suivre la collection"],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GNSsCc":["Impossible de créer la collection"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"GsRMX3":["Playlist not found"],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"I-x669":["Invités"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"ImOQa9":["Répondre"],"J2eKUI":["Fichier"],"JJ-Bhk":["Votre adresse e-mail"],"JRQitQ":["Confirmer le nouveau mot de passe"],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"K1JdNl":["Username already exists"],"KDGWg5":["Retirer de la collection"],"K_F6pa":["Enregistrement…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"LPAv9E":["il y a ",["days"],"j"],"Lld1jm":["Parlez de vous…"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"Mq2B8E":["il y a ",["mins"],"min"],"NR0xa9":["Dites à la communauté pourquoi ça vaut le coup…"],"Nn4kr3":["+ Nouvelle reco"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q6n4F4":["Actualiser les métadonnées"],"QKsaQr":["ou <0>parcourir les fichiers"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"]," a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"SBTElJ":["Recherche…"],"Sad2tK":["Envoi…"],"Sxm8rQ":["Utilisateurs"],"T9bjWt":["<0>",["0"]," a été ajouté à <1>",["1"],""],"TM1ZbA":["Collections (",["0"],["1"],")"],"TN382O":["Lien invalide"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U7u3q-":["+ Nouveau"],"UNMVei":["Mot de passe oublié ?"],"UOZith":["Publication échouée"],"UTiUFs":["Récupération…"],"VCoEm-":["Retour à la connexion"],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VyTYmS":["Changer l\'avatar"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"WtkMN8":["Toutes les ",["0","plural",{"one":["#"," reco votée"],"other":["#"," recos votées"]}]," chargées."],"XILg0L":["Invalid email address"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YaSA2K":["Comment not found"],"YpkCca":["Pas encore de collections suivies."],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZmD2o6":["Créer et ajouter"],"_3O5R_":["Échec de la demande"],"_84wxb":["Toutes les ",["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]," chargées."],"_DwR-n":["Création…"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"alBtu4":["Invalid or expired invite"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"cILfnJ":["Supprimer le fichier"],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"d8DZWS":["Ouvrir la recherche"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dSKHAa":["Invalid username or password"],"dTU6Wi":["Password must be at most 128 characters"],"dbc28f":["Pourquoi recommandez-vous ça ?"],"eFSqvc":["Impossible de publier la réponse"],"ePK91l":["Modifier"],"eaUTwS":["Envoyer le lien de réinitialisation"],"ecUA8p":["Aujourd\'hui"],"ef9nPf":["Chargement de la reco…"],"en9o7K":["Impossible de publier le commentaire"],"fGxPOv":["Ajouter une bio…"],"fI-mNw":["Collections"],"f_akpP":["Max 50 Mo"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"]," a publié <1>",["1"],""],"hYgDIe":["Créer"],"he3ygx":["Copier"],"iDNBZe":["Notifications"],"ijVyoK":["Impossible de contacter le serveur. Veuillez réessayer."],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jGrTH0":["Not authenticated"],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"kLttbL":["Inscription échouée"],"kYYCil":["File too large (max 50 MB)"],"klOeIX":["Impossible de changer le mot de passe"],"l3JaOO":["Cannot edit a deleted comment"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"]," vous a mentionné dans <1>",["where"],""],"pSheLH":["Aucun invité pour le moment."],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qvFa8r":["public"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rg9pXu":["Recherche échouée"],"rtpJqV":["Recos (",["0"],["1"],")"],"sBZMWb":["Invalid URL"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"shHs8T":["Saisissez une recherche."],"siMTjB":["File content is not a recognised image (JPEG, PNG, GIF, WebP)"],"smeBfS":["Invitation invalide"],"tfDRzk":["Enregistrer"],"tqKwXl":["Username must be 1–32 characters and contain only letters, numbers, or underscores"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"u4pkXs":["Invite already used"],"uD0qXQ":["Déposez un fichier ici"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vuosjb":["Menu utilisateur"],"vwGkYB":["Password must be at least 8 characters"],"wXO4Tg":["il y a ",["hrs"],"h"],"wbXKOv":["Fichier trop volumineux (max 50 Mo)."],"wdiqRH":["Admin access required"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter"],"x4aBfU":["Dump not found"],"xEWkgZ":["← Retour à toutes les recos"],"xOTzt5":["à l\'instant"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}', ), }; diff --git a/src/locales/fr.po b/src/locales/fr.po index aa6fa01..a3b72b1 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -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" msgstr "Vous avez déjà un compte ? <0>Se connecter" -#: 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" msgstr "ou <0>parcourir les fichiers" -#: 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." diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx new file mode 100644 index 0000000..3133414 --- /dev/null +++ b/src/pages/ResetPassword.tsx @@ -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({ status: "idle" }); + + const mismatch = confirm.length > 0 && newPassword !== confirm; + const tooShort = newPassword.length > 0 && + newPassword.length < VALIDATION.PASSWORD_MIN; + + if (!token) { + return ( + +
+

+ Invalid link +

+

+ This reset link is missing or malformed. +

+ + Back to login + +
+ + ); + } + + if (state.status === "done") { + return ( + +
+

+ Password updated +

+

+ Your password has been changed. You can now log in. +

+ +
+
+ ); + } + + 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 ( + +
+

+ Set new password +

+ + {state.status === "error" && ( + + )} + +
+
+ setNewPassword(e.target.value)} + autoComplete="new-password" + minLength={VALIDATION.PASSWORD_MIN} + maxLength={VALIDATION.PASSWORD_MAX} + required + autoFocus + disabled={state.status === "submitting"} + /> + {tooShort && ( + + At least {VALIDATION.PASSWORD_MIN} characters + + )} +
+
+ setConfirm(e.target.value)} + autoComplete="new-password" + required + disabled={state.status === "submitting"} + /> + {mismatch && ( + + Passwords do not match + + )} +
+ +
+ +

+ + Back to login + +

+
+
+ ); +} diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx index 82acdd4..453b6d8 100644 --- a/src/pages/UserLogin.tsx +++ b/src/pages/UserLogin.tsx @@ -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({ status: "idle" }); + const [loginState, setLoginState] = useState({ status: "idle" }); + const [showReset, setShowReset] = useState(false); + const [resetEmail, setResetEmail] = useState(""); + const [resetState, setResetState] = useState({ status: "idle" }); const handleSubmit = async (e: SubmitEvent) => { 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() { Log in - {state.status === "error" && ( - + {loginState.status === "error" && ( + )}
@@ -73,7 +97,7 @@ export function UserLogin() { type="text" placeholder={t`Username`} required - disabled={state.status === "submitting"} + disabled={loginState.status === "submitting"} autoFocus />
+

+ +

+ + {showReset && ( +
+ {resetState.status === "sent" + ? ( +

+ + If that address is registered you'll receive a reset link + shortly. + +

+ ) + : ( + <> + {resetState.status === "error" && ( + + )} +
+ setResetEmail(e.target.value)} + required + autoFocus + disabled={resetState.status === "submitting"} + /> + +
+ + )} +
+ )} +

This is a mirage.

diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index b4a4878..e1abca7 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -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(null); const [inviteTreeState, setInviteTreeState] = useState(null); @@ -1202,8 +1204,31 @@ export function UserPublicProfile() { )} + {changePasswordOpen && ( + setChangePasswordOpen(false)} /> + )} + {tab === "settings" && isOwnProfile && ( <> +
+

+ Account +

+
+
+ + Password + + +
+
+

Appearance diff --git a/src/utils/urls.ts b/src/utils/urls.ts index ce425f7..6c4d395 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -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}`; }