v3: added password change/reset feature
This commit is contained in:
@@ -20,6 +20,13 @@ GERBEUR_PORT=8000
|
|||||||
# Example: http://localhost:3000,http://127.0.0.1:3000
|
# Example: http://localhost:3000,http://127.0.0.1:3000
|
||||||
GERBEUR_ALLOWED_ORIGINS=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
|
# Secret key used to sign JWTs. Generate with: openssl rand -hex 32
|
||||||
GERBEUR_JWT_SECRET=
|
GERBEUR_JWT_SECRET=
|
||||||
|
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -1,6 +1,6 @@
|
|||||||
# gerbeur
|
# 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
|
## 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:
|
See [`.env.example`](.env.example) for the full list with descriptions. Key variables:
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- |
|
| ---------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||||
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
|
| `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_PROTOCOL` | Protocol the API server listens on (`http` or `https`) | `http` |
|
||||||
| `GERBEUR_SITE_NAME` | Site name used in OG meta tags | `gerbeur` |
|
| `GERBEUR_HOSTNAME` | Public hostname used in generated URLs (e.g. OG image tags) | `localhost` |
|
||||||
| `GERBEUR_PORT` | API server port | `8000` |
|
| `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` |
|
| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` |
|
||||||
| `VITE_API_HOSTNAME` | Override API hostname in the frontend bundle (see [Production](#production)) | unset |
|
| `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
|
## Production
|
||||||
|
|
||||||
@@ -114,7 +122,10 @@ src/ # React frontend (Vite)
|
|||||||
config/api.ts # API base URL, validation constants
|
config/api.ts # API base URL, validation constants
|
||||||
pages/ # Route-level components
|
pages/ # Route-level components
|
||||||
components/ # Shared UI components
|
components/ # Shared UI components
|
||||||
contexts/ # Auth, WebSocket, player, follows
|
contexts/ # Auth, WebSocket, player, follows, theme
|
||||||
hooks/ # Data fetching and UI hooks
|
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)
|
public/ # Static assets (favicon, icon sprite)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
// 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 LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0";
|
||||||
export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
|
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";
|
export const DB_PATH = "api/sql/gerbeur.db";
|
||||||
|
|
||||||
// Upload/files
|
// Upload/files
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
InvitePayload,
|
InvitePayload,
|
||||||
isAuthPayload,
|
isAuthPayload,
|
||||||
isInvitePayload,
|
isInvitePayload,
|
||||||
|
isPasswordResetPayload,
|
||||||
|
type PasswordResetPayload,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { JWT_SECRET } from "../config.ts";
|
import { JWT_SECRET } from "../config.ts";
|
||||||
|
|
||||||
@@ -38,6 +40,32 @@ export async function verifyInviteToken(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Password reset tokens ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createPasswordResetToken(
|
||||||
|
userId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return await new SignJWT({ purpose: "password-reset", userId })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setJti(crypto.randomUUID())
|
||||||
|
.setExpirationTime("1h")
|
||||||
|
.sign(JWT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPasswordResetToken(
|
||||||
|
token: string,
|
||||||
|
): Promise<PasswordResetPayload | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, JWT_KEY);
|
||||||
|
if (!isPasswordResetPayload(payload)) return null;
|
||||||
|
return payload as PasswordResetPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth tokens ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createJWT(
|
export async function createJWT(
|
||||||
payload: Omit<AuthPayload, "exp">,
|
payload: Omit<AuthPayload, "exp">,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
// ── Invites ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface InviteRow {
|
export interface InviteRow {
|
||||||
|
|||||||
@@ -177,6 +177,22 @@ export function isInvitePayload(obj: unknown): obj is InvitePayload {
|
|||||||
typeof (obj as Record<string, unknown>).inviterId === "string";
|
typeof (obj as Record<string, unknown>).inviterId === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetPayload {
|
||||||
|
purpose: "password-reset";
|
||||||
|
userId: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPasswordResetPayload(
|
||||||
|
obj: unknown,
|
||||||
|
): obj is PasswordResetPayload {
|
||||||
|
return !!obj && typeof obj === "object" &&
|
||||||
|
"purpose" in obj &&
|
||||||
|
(obj as Record<string, unknown>).purpose === "password-reset" &&
|
||||||
|
"userId" in obj &&
|
||||||
|
typeof (obj as Record<string, unknown>).userId === "string";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API
|
* API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,8 +22,17 @@ import {
|
|||||||
updateUser,
|
updateUser,
|
||||||
} from "../services/user-service.ts";
|
} from "../services/user-service.ts";
|
||||||
import { redeemInvite, validateInvite } from "../services/invite-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 { 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 { marked } from "marked";
|
||||||
import { broadcastUserUpdated } from "../services/ws-service.ts";
|
import { broadcastUserUpdated } from "../services/ws-service.ts";
|
||||||
import {
|
import {
|
||||||
@@ -163,6 +172,74 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Request a password reset email (unauthenticated; always returns 200)
|
||||||
|
router.post("/request-password-reset", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const email = typeof body?.email === "string" ? body.email.trim() : "";
|
||||||
|
if (email) {
|
||||||
|
await requestPasswordReset(email).catch((err) =>
|
||||||
|
console.error("[request-password-reset]", err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ctx.response.body = { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consume a reset token and set a new password (unauthenticated)
|
||||||
|
router.post("/reset-password", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { token, newPassword } = (body ?? {}) as Record<string, unknown>;
|
||||||
|
if (typeof token !== "string" || typeof newPassword !== "string") {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"Invalid request",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await resetPassword(token, newPassword);
|
||||||
|
ctx.response.body = { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change current user's password (requires current password verification)
|
||||||
|
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { currentPassword, newPassword } = (body ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"Invalid request",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
newPassword.length < VALIDATION.PASSWORD_MIN ||
|
||||||
|
newPassword.length > VALIDATION.PASSWORD_MAX
|
||||||
|
) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
`Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = getUserById(ctx.state.user.userId);
|
||||||
|
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
401,
|
||||||
|
"Current password is incorrect",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(ctx.state.user.userId, { password: newPassword });
|
||||||
|
ctx.response.body = { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
// Update current user profile (description, etc.)
|
// Update current user profile (description, etc.)
|
||||||
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
|
|||||||
109
api/services/password-reset-service.ts
Normal file
109
api/services/password-reset-service.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import { db, isPasswordResetTokenRow } from "../model/db.ts";
|
||||||
|
import {
|
||||||
|
createPasswordResetToken,
|
||||||
|
verifyPasswordResetToken,
|
||||||
|
} from "../lib/jwt.ts";
|
||||||
|
import { getUserByEmail, updateUser } from "./user-service.ts";
|
||||||
|
import { isEmailEnabled, sendEmail } from "./email-service.ts";
|
||||||
|
import {
|
||||||
|
FROM_EMAIL,
|
||||||
|
FRONTEND_URL,
|
||||||
|
OG_SITE_NAME,
|
||||||
|
VALIDATION,
|
||||||
|
} from "../config.ts";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
const RESET_TOKEN_TTL_HOURS = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up the user by email, creates a signed reset token, persists it, and
|
||||||
|
* sends the reset link. Always resolves without throwing so callers can return
|
||||||
|
* 200 unconditionally (no user enumeration).
|
||||||
|
*/
|
||||||
|
export async function requestPasswordReset(email: string): Promise<void> {
|
||||||
|
const user = getUserByEmail(email);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
if (!isEmailEnabled()) return;
|
||||||
|
|
||||||
|
const token = await createPasswordResetToken(user.id);
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + RESET_TOKEN_TTL_HOURS * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
|
||||||
|
).run(token, user.id, expiresAt.toISOString());
|
||||||
|
|
||||||
|
const resetUrl = `${FRONTEND_URL}/reset-password?token=${
|
||||||
|
encodeURIComponent(token)
|
||||||
|
}`;
|
||||||
|
const body =
|
||||||
|
`# Reset your ${OG_SITE_NAME} password\n\nHi **${user.username}**,\n\nClick the link below to set a new password. It expires in ${RESET_TOKEN_TTL_HOURS} hour.\n\n[Reset password](${resetUrl})\n\nIf you did not request this, ignore this email.`;
|
||||||
|
|
||||||
|
sendEmail({
|
||||||
|
from: FROM_EMAIL,
|
||||||
|
to: user.email,
|
||||||
|
subject: `Reset your ${OG_SITE_NAME} password`,
|
||||||
|
text: body,
|
||||||
|
html: await marked(body),
|
||||||
|
}).catch((err) =>
|
||||||
|
console.error("[password-reset] failed to send email:", err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the token (JWT signature + expiry + DB record + not used), updates
|
||||||
|
* the password, and marks the token consumed.
|
||||||
|
*/
|
||||||
|
export async function resetPassword(
|
||||||
|
token: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
newPassword.length < VALIDATION.PASSWORD_MIN ||
|
||||||
|
newPassword.length > VALIDATION.PASSWORD_MAX
|
||||||
|
) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
`Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await verifyPasswordResetToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"Invalid or expired reset link",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT token, user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?;`,
|
||||||
|
).get(token);
|
||||||
|
|
||||||
|
if (!row || !isPasswordResetTokenRow(row)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.NOT_FOUND,
|
||||||
|
404,
|
||||||
|
"Reset token not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.used_at !== null) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
409,
|
||||||
|
"Reset link has already been used",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(payload.userId, { password: newPassword });
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ?;`,
|
||||||
|
).run(new Date().toISOString(), token);
|
||||||
|
}
|
||||||
@@ -85,6 +85,15 @@ export function getUserByUsername(username: string): User {
|
|||||||
return userRowToApi(userRow);
|
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(
|
export function searchUsers(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ CREATE TABLE attachments (
|
|||||||
|
|
||||||
CREATE INDEX idx_attachments_resource ON attachments(resource_id);
|
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 (
|
CREATE TABLE notifications (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"server:start": "deno run --env-file -A --watch api/main.ts",
|
"server:start": "deno run --env-file -A --watch api/main.ts",
|
||||||
"serve": "deno task build && deno run -A server:start",
|
"serve": "deno task build && deno run -A server:start",
|
||||||
"i18n:extract": "deno run -A scripts/lingui-extract.ts",
|
"i18n:extract": "deno run -A scripts/lingui-extract.ts",
|
||||||
"i18n:compile": "lingui compile"
|
"i18n:compile": "deno run -A scripts/lingui-compile.ts"
|
||||||
},
|
},
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|||||||
5
scripts/lingui-compile.ts
Normal file
5
scripts/lingui-compile.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { command as compile } from "../node_modules/@lingui/cli/dist/lingui-compile.js";
|
||||||
|
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
|
||||||
|
|
||||||
|
const config = getConfig({ cwd: Deno.cwd() });
|
||||||
|
await compile(config, { watch: false, namespace: undefined, typescript: false, allowEmpty: true, workersOptions: { poolSize: 0 } });
|
||||||
45
src/App.css
45
src/App.css
@@ -2021,6 +2021,51 @@ body.has-player .fab-new {
|
|||||||
margin: 0;
|
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) ── */
|
/* ── Form pages (DumpCreate / DumpEdit) ── */
|
||||||
@keyframes page-enter {
|
@keyframes page-enter {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { UserPlaylists } from "./pages/UserPlaylists.tsx";
|
|||||||
import { PlaylistDetail } from "./pages/PlaylistDetail.tsx";
|
import { PlaylistDetail } from "./pages/PlaylistDetail.tsx";
|
||||||
import { Notifications } from "./pages/Notifications.tsx";
|
import { Notifications } from "./pages/Notifications.tsx";
|
||||||
import { Search } from "./pages/Search.tsx";
|
import { Search } from "./pages/Search.tsx";
|
||||||
|
import { ResetPassword } from "./pages/ResetPassword.tsx";
|
||||||
|
|
||||||
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
||||||
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
||||||
@@ -62,6 +63,7 @@ function AppRoutes() {
|
|||||||
/>
|
/>
|
||||||
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
||||||
<Route path="/search" element={<Search />} />
|
<Route path="/search" element={<Search />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route
|
<Route
|
||||||
path="/notifications"
|
path="/notifications"
|
||||||
element={
|
element={
|
||||||
|
|||||||
186
src/components/ChangePasswordModal.tsx
Normal file
186
src/components/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { ErrorCard } from "./ErrorCard.tsx";
|
||||||
|
import { Modal } from "./Modal.tsx";
|
||||||
|
|
||||||
|
interface ChangePasswordModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const mismatch = confirmPassword.length > 0 &&
|
||||||
|
newPassword !== confirmPassword;
|
||||||
|
const tooShort = newPassword.length > 0 &&
|
||||||
|
newPassword.length < VALIDATION.PASSWORD_MIN;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (mismatch || tooShort || !currentPassword || !newPassword) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${API_URL}/api/users/me/change-password`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body.success) {
|
||||||
|
setError(body.error?.message ?? t`Unknown error`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDone(true);
|
||||||
|
} catch {
|
||||||
|
setError(t`Failed to change password`);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t`Change password`} onClose={onClose}>
|
||||||
|
{done
|
||||||
|
? (
|
||||||
|
<div className="modal-new-playlist-form">
|
||||||
|
<p style={{ color: "var(--color-success, green)" }}>
|
||||||
|
<Trans>Password changed successfully.</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="form-actions">
|
||||||
|
<div className="form-actions-right">
|
||||||
|
<button type="button" className="btn-primary" onClick={onClose}>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Current password</Trans>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>New password</Trans>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={VALIDATION.PASSWORD_MIN}
|
||||||
|
maxLength={VALIDATION.PASSWORD_MAX}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{tooShort && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--color-error, red)",
|
||||||
|
marginTop: "0.2rem",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Confirm new password</Trans>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{mismatch && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--color-error, red)",
|
||||||
|
marginTop: "0.2rem",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Passwords do not match</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<ErrorCard title={t`Could not change password`} message={error} />
|
||||||
|
)}
|
||||||
|
<div className="form-actions">
|
||||||
|
<div className="form-actions-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={submitting || mismatch || tooShort ||
|
||||||
|
!currentPassword || !newPassword || !confirmPassword}
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? <Trans>Saving…</Trans>
|
||||||
|
: <Trans>Change password</Trans>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.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 RichContentCard from "./RichContentCard.tsx";
|
||||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
import type { RichContent } from "../model.ts";
|
import type { RichContent } from "../model.ts";
|
||||||
@@ -29,14 +30,6 @@ import { Modal } from "./Modal.tsx";
|
|||||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
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 Mode = "url" | "file";
|
||||||
type Phase = "create" | "playlist";
|
type Phase = "create" | "playlist";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from "react";
|
|||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import { formatBytes } from "../utils/format.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 { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||||
import {
|
import {
|
||||||
BAR_GAP,
|
BAR_GAP,
|
||||||
@@ -76,26 +76,7 @@ function AudioFilePreview(
|
|||||||
onClick={handlePlayBtn}
|
onClick={handlePlayBtn}
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
>
|
>
|
||||||
{isPlaying
|
{isPlaying ? <IconPause /> : <IconPlay />}
|
||||||
? (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
style={{ padding: "1px" }}
|
|
||||||
>
|
|
||||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
|
||||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
style={{ marginLeft: "2px" }}
|
|
||||||
>
|
|
||||||
<polygon points="6,3 20,12 6,21" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
{peaks
|
{peaks
|
||||||
? (
|
? (
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ function fmt(s: number): string {
|
|||||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconPlay = () => (
|
export const IconPlay = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||||
<polygon points="6,3 20,12 6,21" />
|
<polygon points="6,3 20,12 6,21" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconPause = () => (
|
export const IconPause = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -54,7 +54,7 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
|||||||
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:611
|
#: src/pages/PlaylistDetail.tsx:611
|
||||||
#: src/pages/UserPublicProfile.tsx:728
|
#: src/pages/UserPublicProfile.tsx:745
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Back"
|
msgstr "← Back"
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ msgstr "← Back to all dumps"
|
|||||||
msgid "← Back to profile"
|
msgid "← Back to profile"
|
||||||
msgstr "← Back to profile"
|
msgstr "← Back to profile"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:93
|
#: src/pages/UserPublicProfile.tsx:100
|
||||||
msgid "+ Invite someone"
|
msgid "+ Invite someone"
|
||||||
msgstr "+ Invite someone"
|
msgstr "+ Invite someone"
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ msgid "+ New"
|
|||||||
msgstr "+ New"
|
msgstr "+ New"
|
||||||
|
|
||||||
#: src/pages/UserDumps.tsx:114
|
#: src/pages/UserDumps.tsx:114
|
||||||
#: src/pages/UserPublicProfile.tsx:1282
|
#: src/pages/UserPublicProfile.tsx:1330
|
||||||
msgid "+ New dump"
|
msgid "+ New dump"
|
||||||
msgstr "+ New dump"
|
msgstr "+ New dump"
|
||||||
|
|
||||||
@@ -134,7 +134,11 @@ msgstr "a comment"
|
|||||||
msgid "a post"
|
msgid "a post"
|
||||||
msgstr "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…"
|
msgid "Add a bio…"
|
||||||
msgstr "Add a bio…"
|
msgstr "Add a bio…"
|
||||||
|
|
||||||
@@ -142,12 +146,12 @@ msgstr "Add a bio…"
|
|||||||
msgid "Add a comment…"
|
msgid "Add a comment…"
|
||||||
msgstr "Add a comment…"
|
msgstr "Add a comment…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:842
|
#: src/pages/UserPublicProfile.tsx:859
|
||||||
msgid "Add email…"
|
msgid "Add email…"
|
||||||
msgstr "Add email…"
|
msgstr "Add email…"
|
||||||
|
|
||||||
#: src/components/AddToPlaylistModal.tsx:64
|
#: src/components/AddToPlaylistModal.tsx:64
|
||||||
#: src/components/DumpCreateModal.tsx:284
|
#: src/components/DumpCreateModal.tsx:277
|
||||||
msgid "Add to playlist"
|
msgid "Add to playlist"
|
||||||
msgstr "Add to playlist"
|
msgstr "Add to playlist"
|
||||||
|
|
||||||
@@ -167,29 +171,41 @@ msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
|||||||
msgid "Already have an account? <0>Log in</0>"
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
msgstr "Already have an account? <0>Log in</0>"
|
msgstr "Already have an account? <0>Log in</0>"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1186
|
#: src/pages/UserPublicProfile.tsx:1234
|
||||||
msgid "Appearance"
|
msgid "Appearance"
|
||||||
msgstr "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"
|
msgid "Auto"
|
||||||
msgstr "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:168
|
||||||
#: src/contexts/WSProvider.tsx:360
|
#: src/contexts/WSProvider.tsx:360
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
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."
|
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:281
|
||||||
#: src/components/CommentThread.tsx:373
|
#: src/components/CommentThread.tsx:373
|
||||||
#: src/components/CommentThread.tsx:510
|
#: src/components/CommentThread.tsx:510
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
#: src/components/DumpCreateModal.tsx:422
|
#: src/components/DumpCreateModal.tsx:415
|
||||||
#: src/components/PlaylistCreateForm.tsx:112
|
#: src/components/PlaylistCreateForm.tsx:112
|
||||||
#: src/pages/DumpEdit.tsx:299
|
#: src/pages/DumpEdit.tsx:299
|
||||||
#: src/pages/PlaylistDetail.tsx:680
|
#: src/pages/PlaylistDetail.tsx:680
|
||||||
#: src/pages/UserPublicProfile.tsx:824
|
#: src/pages/UserPublicProfile.tsx:841
|
||||||
#: src/pages/UserPublicProfile.tsx:902
|
#: src/pages/UserPublicProfile.tsx:919
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
|
|
||||||
@@ -201,19 +217,29 @@ msgstr "Cancel removal"
|
|||||||
#~ msgid "Cannot edit a deleted comment"
|
#~ msgid "Cannot edit a deleted comment"
|
||||||
#~ msgstr "Cannot edit a deleted comment"
|
#~ msgstr "Cannot edit a deleted comment"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:755
|
#: src/pages/UserPublicProfile.tsx:772
|
||||||
msgid "Change avatar"
|
msgid "Change avatar"
|
||||||
msgstr "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
|
#: src/pages/UserRegister.tsx:95
|
||||||
msgid "Checking invite…"
|
msgid "Checking invite…"
|
||||||
msgstr "Checking invite…"
|
msgstr "Checking invite…"
|
||||||
|
|
||||||
|
#: src/components/ChangePasswordModal.tsx:65
|
||||||
#: src/components/Modal.tsx:45
|
#: src/components/Modal.tsx:45
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Close"
|
msgstr "Close"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1212
|
#: src/pages/UserPublicProfile.tsx:1260
|
||||||
msgid "Color scheme"
|
msgid "Color scheme"
|
||||||
msgstr "Color scheme"
|
msgstr "Color scheme"
|
||||||
|
|
||||||
@@ -221,14 +247,28 @@ msgstr "Color scheme"
|
|||||||
#~ msgid "Comment not found"
|
#~ msgid "Comment not found"
|
||||||
#~ msgstr "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!"
|
msgid "Copied!"
|
||||||
msgstr "Copied!"
|
msgstr "Copied!"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:84
|
#: src/pages/UserPublicProfile.tsx:91
|
||||||
msgid "Copy"
|
msgid "Copy"
|
||||||
msgstr "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:111
|
||||||
#: src/components/CommentThread.tsx:153
|
#: src/components/CommentThread.tsx:153
|
||||||
#: src/components/CommentThread.tsx:448
|
#: src/components/CommentThread.tsx:448
|
||||||
@@ -253,7 +293,11 @@ msgstr "Created ({0}{1})"
|
|||||||
msgid "Creating…"
|
msgid "Creating…"
|
||||||
msgstr "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"
|
msgid "Dark"
|
||||||
msgstr "Dark"
|
msgstr "Dark"
|
||||||
|
|
||||||
@@ -293,7 +337,7 @@ msgstr "Delete this playlist? This cannot be undone."
|
|||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
msgstr "Description (optional)"
|
msgstr "Description (optional)"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:468
|
#: src/components/DumpCreateModal.tsx:461
|
||||||
msgid "Done"
|
msgid "Done"
|
||||||
msgstr "Done"
|
msgstr "Done"
|
||||||
|
|
||||||
@@ -305,7 +349,7 @@ msgstr "Drop a file here"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Drop a replacement here"
|
msgstr "Drop a replacement here"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:434
|
#: src/components/DumpCreateModal.tsx:427
|
||||||
msgid "Dump it"
|
msgid "Dump it"
|
||||||
msgstr "Dump it"
|
msgstr "Dump it"
|
||||||
|
|
||||||
@@ -313,19 +357,19 @@ msgstr "Dump it"
|
|||||||
#~ msgid "Dump not found"
|
#~ msgid "Dump not found"
|
||||||
#~ msgstr "Dump not found"
|
#~ msgstr "Dump not found"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:445
|
#: src/components/DumpCreateModal.tsx:438
|
||||||
msgid "Dumped!"
|
msgid "Dumped!"
|
||||||
msgstr "Dumped!"
|
msgstr "Dumped!"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:172
|
#: src/pages/Search.tsx:172
|
||||||
#: src/pages/UserDumps.tsx:107
|
#: src/pages/UserDumps.tsx:107
|
||||||
#: src/pages/UserPublicProfile.tsx:950
|
#: src/pages/UserPublicProfile.tsx:967
|
||||||
msgid "Dumps"
|
msgid "Dumps"
|
||||||
msgstr "Dumps"
|
msgstr "Dumps"
|
||||||
|
|
||||||
#. placeholder {0}: dumps.items.length
|
#. placeholder {0}: dumps.items.length
|
||||||
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:987
|
#: src/pages/UserPublicProfile.tsx:1004
|
||||||
msgid "Dumps ({0}{1})"
|
msgid "Dumps ({0}{1})"
|
||||||
msgstr "Dumps ({0}{1})"
|
msgstr "Dumps ({0}{1})"
|
||||||
|
|
||||||
@@ -369,14 +413,18 @@ msgstr "Email address"
|
|||||||
msgid "Enter a query to search."
|
msgid "Enter a query to search."
|
||||||
msgstr "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:62
|
||||||
#: src/components/PlaylistCreateForm.tsx:103
|
#: src/components/PlaylistCreateForm.tsx:103
|
||||||
msgid "Failed to create playlist"
|
msgid "Failed to create playlist"
|
||||||
msgstr "Failed to create playlist"
|
msgstr "Failed to create playlist"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:65
|
#: src/pages/UserPublicProfile.tsx:72
|
||||||
#: src/pages/UserPublicProfile.tsx:68
|
#: src/pages/UserPublicProfile.tsx:75
|
||||||
#: src/pages/UserPublicProfile.tsx:96
|
#: src/pages/UserPublicProfile.tsx:103
|
||||||
msgid "Failed to generate invite"
|
msgid "Failed to generate invite"
|
||||||
msgstr "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/JournalFeed.tsx:48
|
||||||
#: src/pages/index/NewFeed.tsx:36
|
#: src/pages/index/NewFeed.tsx:36
|
||||||
#: src/pages/Notifications.tsx:323
|
#: src/pages/Notifications.tsx:323
|
||||||
#: src/pages/UserPublicProfile.tsx:1081
|
#: src/pages/UserPublicProfile.tsx:1106
|
||||||
#: src/pages/UserPublicProfile.tsx:1118
|
#: src/pages/UserPublicProfile.tsx:1148
|
||||||
#: src/pages/UserPublicProfile.tsx:1160
|
#: src/pages/UserPublicProfile.tsx:1193
|
||||||
msgid "Failed to load"
|
msgid "Failed to load"
|
||||||
msgstr "Failed to load"
|
msgstr "Failed to load"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:322
|
#: src/components/DumpCreateModal.tsx:315
|
||||||
msgid "Failed to post"
|
msgid "Failed to post"
|
||||||
msgstr "Failed to post"
|
msgstr "Failed to post"
|
||||||
|
|
||||||
@@ -404,10 +452,10 @@ msgid "Failed to post reply"
|
|||||||
msgstr "Failed to post reply"
|
msgstr "Failed to post reply"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:789
|
#: src/pages/PlaylistDetail.tsx:789
|
||||||
#: src/pages/UserPublicProfile.tsx:663
|
#: src/pages/UserPublicProfile.tsx:680
|
||||||
#: src/pages/UserPublicProfile.tsx:701
|
#: src/pages/UserPublicProfile.tsx:718
|
||||||
#: src/pages/UserPublicProfile.tsx:828
|
#: src/pages/UserPublicProfile.tsx:845
|
||||||
#: src/pages/UserPublicProfile.tsx:905
|
#: src/pages/UserPublicProfile.tsx:922
|
||||||
msgid "Failed to save"
|
msgid "Failed to save"
|
||||||
msgstr "Failed to save"
|
msgstr "Failed to save"
|
||||||
|
|
||||||
@@ -415,19 +463,19 @@ msgstr "Failed to save"
|
|||||||
msgid "Failed to save edit"
|
msgid "Failed to save edit"
|
||||||
msgstr "Failed to save edit"
|
msgstr "Failed to save edit"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:851
|
#: src/pages/UserPublicProfile.tsx:868
|
||||||
msgid "Failed to update avatar"
|
msgid "Failed to update avatar"
|
||||||
msgstr "Failed to update avatar"
|
msgstr "Failed to update avatar"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:359
|
#: src/components/DumpCreateModal.tsx:352
|
||||||
msgid "Fetching preview…"
|
msgid "Fetching preview…"
|
||||||
msgstr "Fetching preview…"
|
msgstr "Fetching preview…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:432
|
#: src/components/DumpCreateModal.tsx:425
|
||||||
msgid "Fetching…"
|
msgid "Fetching…"
|
||||||
msgstr "Fetching…"
|
msgstr "Fetching…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:315
|
#: src/components/DumpCreateModal.tsx:308
|
||||||
#: src/components/FileDropZone.tsx:31
|
#: src/components/FileDropZone.tsx:31
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "File"
|
msgstr "File"
|
||||||
@@ -444,7 +492,7 @@ msgstr "File"
|
|||||||
#~ msgid "File too large (max 50 MB)"
|
#~ msgid "File too large (max 50 MB)"
|
||||||
#~ msgstr "File too large (max 50 MB)"
|
#~ msgstr "File too large (max 50 MB)"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:209
|
#: src/components/DumpCreateModal.tsx:202
|
||||||
msgid "File too large (max 50 MB)."
|
msgid "File too large (max 50 MB)."
|
||||||
msgstr "File too large (max 50 MB)."
|
msgstr "File too large (max 50 MB)."
|
||||||
|
|
||||||
@@ -470,7 +518,7 @@ msgid "Follow some users to see their dumps here."
|
|||||||
msgstr "Follow some users to see their dumps here."
|
msgstr "Follow some users to see their dumps here."
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:47
|
#: src/components/FeedTabBar.tsx:47
|
||||||
#: src/pages/UserPublicProfile.tsx:964
|
#: src/pages/UserPublicProfile.tsx:981
|
||||||
msgid "Followed"
|
msgid "Followed"
|
||||||
msgstr "Followed"
|
msgstr "Followed"
|
||||||
|
|
||||||
@@ -480,13 +528,13 @@ msgstr "Followed"
|
|||||||
msgid "Followed ({0}{1})"
|
msgid "Followed ({0}{1})"
|
||||||
msgstr "Followed ({0}{1})"
|
msgstr "Followed ({0}{1})"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1109
|
#: src/pages/UserPublicProfile.tsx:1137
|
||||||
msgid "Followed playlists"
|
msgid "Followed playlists"
|
||||||
msgstr "Followed playlists"
|
msgstr "Followed playlists"
|
||||||
|
|
||||||
#: src/components/FollowButton.tsx:37
|
#: src/components/FollowButton.tsx:37
|
||||||
#: src/components/FollowButton.tsx:64
|
#: src/components/FollowButton.tsx:64
|
||||||
#: src/pages/UserPublicProfile.tsx:1072
|
#: src/pages/UserPublicProfile.tsx:1095
|
||||||
msgid "Following"
|
msgid "Following"
|
||||||
msgstr "Following"
|
msgstr "Following"
|
||||||
|
|
||||||
@@ -494,6 +542,10 @@ msgstr "Following"
|
|||||||
#~ msgid "Forbidden"
|
#~ msgid "Forbidden"
|
||||||
#~ msgstr "Forbidden"
|
#~ msgstr "Forbidden"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:131
|
||||||
|
msgid "Forgot password?"
|
||||||
|
msgstr "Forgot password?"
|
||||||
|
|
||||||
#: src/pages/index/FollowedFeed.tsx:337
|
#: src/pages/index/FollowedFeed.tsx:337
|
||||||
msgid "From people"
|
msgid "From people"
|
||||||
msgstr "From people"
|
msgstr "From people"
|
||||||
@@ -502,10 +554,18 @@ msgstr "From people"
|
|||||||
msgid "From playlists"
|
msgid "From playlists"
|
||||||
msgstr "From playlists"
|
msgstr "From playlists"
|
||||||
|
|
||||||
|
#: src/pages/ResetPassword.tsx:56
|
||||||
|
msgid "Go to login"
|
||||||
|
msgstr "Go to login"
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:25
|
#: src/components/FeedTabBar.tsx:25
|
||||||
msgid "Hot"
|
msgid "Hot"
|
||||||
msgstr "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:
|
#: api/auth:
|
||||||
#~ msgid "Invalid email address"
|
#~ msgid "Invalid email address"
|
||||||
#~ msgstr "Invalid email address"
|
#~ msgstr "Invalid email address"
|
||||||
@@ -514,6 +574,10 @@ msgstr "Hot"
|
|||||||
msgid "Invalid invite"
|
msgid "Invalid invite"
|
||||||
msgstr "Invalid invite"
|
msgstr "Invalid invite"
|
||||||
|
|
||||||
|
#: src/pages/ResetPassword.tsx:33
|
||||||
|
msgid "Invalid link"
|
||||||
|
msgstr "Invalid link"
|
||||||
|
|
||||||
#: api/invites:
|
#: api/invites:
|
||||||
#~ msgid "Invalid or expired invite"
|
#~ msgid "Invalid or expired invite"
|
||||||
#~ msgstr "Invalid or expired invite"
|
#~ msgstr "Invalid or expired invite"
|
||||||
@@ -531,12 +595,12 @@ msgstr "Invalid invite"
|
|||||||
#~ msgid "Invite already used"
|
#~ msgid "Invite already used"
|
||||||
#~ msgstr "Invite already used"
|
#~ msgstr "Invite already used"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:773
|
#: src/pages/UserPublicProfile.tsx:790
|
||||||
msgid "invited by"
|
msgid "invited by"
|
||||||
msgstr "invited by"
|
msgstr "invited by"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:971
|
#: src/pages/UserPublicProfile.tsx:988
|
||||||
#: src/pages/UserPublicProfile.tsx:1149
|
#: src/pages/UserPublicProfile.tsx:1182
|
||||||
msgid "Invitees"
|
msgid "Invitees"
|
||||||
msgstr "Invitees"
|
msgstr "Invitees"
|
||||||
|
|
||||||
@@ -548,7 +612,7 @@ msgstr "Journal"
|
|||||||
msgid "just now"
|
msgid "just now"
|
||||||
msgstr "just now"
|
msgstr "just now"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1227
|
#: src/pages/UserPublicProfile.tsx:1275
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Light"
|
msgstr "Light"
|
||||||
|
|
||||||
@@ -585,7 +649,7 @@ msgstr "Loading more…"
|
|||||||
msgid "Loading playlist…"
|
msgid "Loading playlist…"
|
||||||
msgstr "Loading playlist…"
|
msgstr "Loading playlist…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:711
|
#: src/pages/UserPublicProfile.tsx:728
|
||||||
msgid "Loading profile…"
|
msgid "Loading profile…"
|
||||||
msgstr "Loading profile…"
|
msgstr "Loading profile…"
|
||||||
|
|
||||||
@@ -599,29 +663,29 @@ msgstr "Loading profile…"
|
|||||||
#: src/pages/Notifications.tsx:395
|
#: src/pages/Notifications.tsx:395
|
||||||
#: src/pages/UserDumps.tsx:51
|
#: src/pages/UserDumps.tsx:51
|
||||||
#: src/pages/UserPlaylists.tsx:342
|
#: src/pages/UserPlaylists.tsx:342
|
||||||
#: src/pages/UserPublicProfile.tsx:1077
|
#: src/pages/UserPublicProfile.tsx:1100
|
||||||
#: src/pages/UserPublicProfile.tsx:1114
|
#: src/pages/UserPublicProfile.tsx:1142
|
||||||
#: src/pages/UserPublicProfile.tsx:1154
|
#: src/pages/UserPublicProfile.tsx:1187
|
||||||
#: src/pages/UserUpvoted.tsx:123
|
#: src/pages/UserUpvoted.tsx:123
|
||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Loading…"
|
msgstr "Loading…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:74
|
#: src/components/AppHeader.tsx:74
|
||||||
#: src/pages/UserLogin.tsx:63
|
#: src/pages/UserLogin.tsx:87
|
||||||
#: src/pages/UserLogin.tsx:93
|
#: src/pages/UserLogin.tsx:117
|
||||||
msgid "Log in"
|
msgid "Log in"
|
||||||
msgstr "Log in"
|
msgstr "Log in"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:732
|
#: src/pages/UserPublicProfile.tsx:749
|
||||||
#: src/pages/UserPublicProfile.tsx:865
|
#: src/pages/UserPublicProfile.tsx:882
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Log out"
|
msgstr "Log out"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:92
|
#: src/pages/UserLogin.tsx:116
|
||||||
msgid "Logging in…"
|
msgid "Logging in…"
|
||||||
msgstr "Logging in…"
|
msgstr "Logging in…"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:67
|
#: src/pages/UserLogin.tsx:91
|
||||||
msgid "Login failed"
|
msgid "Login failed"
|
||||||
msgstr "Login failed"
|
msgstr "Login failed"
|
||||||
|
|
||||||
@@ -637,10 +701,15 @@ msgstr "new"
|
|||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "New"
|
msgstr "New"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:284
|
#: src/components/DumpCreateModal.tsx:277
|
||||||
msgid "New dump"
|
msgid "New dump"
|
||||||
msgstr "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
|
#: src/components/NewPlaylistForm.tsx:34
|
||||||
msgid "New playlist"
|
msgid "New playlist"
|
||||||
msgstr "New playlist"
|
msgstr "New playlist"
|
||||||
@@ -664,11 +733,11 @@ msgid "No emoji found."
|
|||||||
msgstr "No emoji found."
|
msgstr "No emoji found."
|
||||||
|
|
||||||
#: src/pages/UserPlaylists.tsx:439
|
#: src/pages/UserPlaylists.tsx:439
|
||||||
#: src/pages/UserPublicProfile.tsx:1122
|
#: src/pages/UserPublicProfile.tsx:1155
|
||||||
msgid "No followed playlists yet."
|
msgid "No followed playlists yet."
|
||||||
msgstr "No followed playlists yet."
|
msgstr "No followed playlists yet."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1167
|
#: src/pages/UserPublicProfile.tsx:1200
|
||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "No invitees yet."
|
msgstr "No invitees yet."
|
||||||
|
|
||||||
@@ -678,7 +747,7 @@ msgstr "No playlists match \"{q}\"."
|
|||||||
|
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:34
|
#: src/components/PlaylistMembershipPanel.tsx:34
|
||||||
#: src/pages/UserPlaylists.tsx:397
|
#: src/pages/UserPlaylists.tsx:397
|
||||||
#: src/pages/UserPublicProfile.tsx:1043
|
#: src/pages/UserPublicProfile.tsx:1066
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "No playlists yet."
|
msgstr "No playlists yet."
|
||||||
|
|
||||||
@@ -690,14 +759,14 @@ msgstr "No users match \"{q}\"."
|
|||||||
#~ msgid "Not authenticated"
|
#~ msgid "Not authenticated"
|
||||||
#~ msgstr "Not authenticated"
|
#~ msgstr "Not authenticated"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1085
|
#: src/pages/UserPublicProfile.tsx:1113
|
||||||
msgid "Not following anyone yet."
|
msgid "Not following anyone yet."
|
||||||
msgstr "Not following anyone yet."
|
msgstr "Not following anyone yet."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:330
|
#: src/pages/Notifications.tsx:330
|
||||||
#: src/pages/UserDumps.tsx:123
|
#: src/pages/UserDumps.tsx:123
|
||||||
#: src/pages/UserPublicProfile.tsx:1292
|
#: src/pages/UserPublicProfile.tsx:1340
|
||||||
#: src/pages/UserPublicProfile.tsx:1415
|
#: src/pages/UserPublicProfile.tsx:1463
|
||||||
#: src/pages/UserUpvoted.tsx:195
|
#: src/pages/UserUpvoted.tsx:195
|
||||||
msgid "Nothing here yet."
|
msgid "Nothing here yet."
|
||||||
msgstr "Nothing here yet."
|
msgstr "Nothing here yet."
|
||||||
@@ -719,7 +788,8 @@ msgstr "Open search"
|
|||||||
msgid "or <0>browse files</0>"
|
msgid "or <0>browse files</0>"
|
||||||
msgstr "or <0>browse files</0>"
|
msgstr "or <0>browse files</0>"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:82
|
#: src/pages/UserLogin.tsx:106
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1220
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
@@ -728,6 +798,10 @@ msgstr "Password"
|
|||||||
msgid "Password (min. {0} characters)"
|
msgid "Password (min. {0} characters)"
|
||||||
msgstr "Password (min. {0} characters)"
|
msgstr "Password (min. {0} characters)"
|
||||||
|
|
||||||
|
#: src/components/ChangePasswordModal.tsx:60
|
||||||
|
msgid "Password changed successfully."
|
||||||
|
msgstr "Password changed successfully."
|
||||||
|
|
||||||
#: api/auth:
|
#: api/auth:
|
||||||
#~ msgid "Password must be at least 8 characters"
|
#~ msgid "Password must be at least 8 characters"
|
||||||
#~ msgstr "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"
|
#~ msgid "Password must be at most 128 characters"
|
||||||
#~ msgstr "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:
|
#: api/playlists:
|
||||||
#~ msgid "Playlist not found"
|
#~ msgid "Playlist not found"
|
||||||
#~ msgstr "Playlist not found"
|
#~ msgstr "Playlist not found"
|
||||||
@@ -744,17 +827,17 @@ msgstr "Password (min. {0} characters)"
|
|||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:175
|
#: src/pages/Search.tsx:175
|
||||||
#: src/pages/UserPlaylists.tsx:368
|
#: src/pages/UserPlaylists.tsx:368
|
||||||
#: src/pages/UserPublicProfile.tsx:957
|
#: src/pages/UserPublicProfile.tsx:974
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
msgstr "Playlists"
|
msgstr "Playlists"
|
||||||
|
|
||||||
#. placeholder {0}: playlists.items.length
|
#. placeholder {0}: playlists.items.length
|
||||||
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:1016
|
#: src/pages/UserPublicProfile.tsx:1035
|
||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Playlists ({0}{1})"
|
msgstr "Playlists ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:202
|
#: src/components/DumpCreateModal.tsx:195
|
||||||
msgid "Please select a file."
|
msgid "Please select a file."
|
||||||
msgstr "Please select a file."
|
msgstr "Please select a file."
|
||||||
|
|
||||||
@@ -779,7 +862,7 @@ msgstr "Posting…"
|
|||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "private"
|
msgstr "private"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:411
|
#: src/components/DumpCreateModal.tsx:404
|
||||||
#: src/components/PlaylistCreateForm.tsx:99
|
#: src/components/PlaylistCreateForm.tsx:99
|
||||||
#: src/pages/DumpEdit.tsx:285
|
#: src/pages/DumpEdit.tsx:285
|
||||||
#: src/pages/PlaylistDetail.tsx:746
|
#: src/pages/PlaylistDetail.tsx:746
|
||||||
@@ -791,7 +874,7 @@ msgstr "Private"
|
|||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:403
|
#: src/components/DumpCreateModal.tsx:396
|
||||||
#: src/components/PlaylistCreateForm.tsx:92
|
#: src/components/PlaylistCreateForm.tsx:92
|
||||||
#: src/pages/DumpEdit.tsx:278
|
#: src/pages/DumpEdit.tsx:278
|
||||||
#: src/pages/PlaylistDetail.tsx:739
|
#: src/pages/PlaylistDetail.tsx:739
|
||||||
@@ -835,6 +918,14 @@ msgstr "Replace file"
|
|||||||
msgid "Reply"
|
msgid "Reply"
|
||||||
msgstr "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/Dump.tsx:211
|
||||||
#: src/pages/DumpEdit.tsx:163
|
#: src/pages/DumpEdit.tsx:163
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
@@ -843,15 +934,17 @@ msgstr "Retry"
|
|||||||
#: src/components/CommentThread.tsx:270
|
#: src/components/CommentThread.tsx:270
|
||||||
#: src/pages/DumpEdit.tsx:306
|
#: src/pages/DumpEdit.tsx:306
|
||||||
#: src/pages/PlaylistDetail.tsx:673
|
#: src/pages/PlaylistDetail.tsx:673
|
||||||
#: src/pages/UserPublicProfile.tsx:816
|
#: src/pages/UserPublicProfile.tsx:833
|
||||||
#: src/pages/UserPublicProfile.tsx:894
|
#: src/pages/UserPublicProfile.tsx:911
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Save"
|
msgstr "Save"
|
||||||
|
|
||||||
|
#: src/components/ChangePasswordModal.tsx:141
|
||||||
#: src/components/CommentThread.tsx:269
|
#: src/components/CommentThread.tsx:269
|
||||||
#: src/pages/PlaylistDetail.tsx:673
|
#: src/pages/PlaylistDetail.tsx:673
|
||||||
#: src/pages/UserPublicProfile.tsx:815
|
#: src/pages/ResetPassword.tsx:140
|
||||||
#: src/pages/UserPublicProfile.tsx:894
|
#: src/pages/UserPublicProfile.tsx:832
|
||||||
|
#: src/pages/UserPublicProfile.tsx:911
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Saving…"
|
msgstr "Saving…"
|
||||||
|
|
||||||
@@ -871,11 +964,24 @@ msgstr "Search failed"
|
|||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "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
|
#: src/components/AppHeader.tsx:65
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "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"
|
msgid "Settings"
|
||||||
msgstr "Settings"
|
msgstr "Settings"
|
||||||
|
|
||||||
@@ -883,7 +989,7 @@ msgstr "Settings"
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Something went wrong"
|
msgstr "Something went wrong"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1191
|
#: src/pages/UserPublicProfile.tsx:1239
|
||||||
msgid "Style"
|
msgid "Style"
|
||||||
msgstr "Style"
|
msgstr "Style"
|
||||||
|
|
||||||
@@ -891,11 +997,11 @@ msgstr "Style"
|
|||||||
msgid "Submit search"
|
msgid "Submit search"
|
||||||
msgstr "Submit search"
|
msgstr "Submit search"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:882
|
#: src/pages/UserPublicProfile.tsx:899
|
||||||
msgid "Tell people about yourself…"
|
msgid "Tell people about yourself…"
|
||||||
msgstr "Tell people about yourself…"
|
msgstr "Tell people about yourself…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:390
|
#: src/components/DumpCreateModal.tsx:383
|
||||||
#: src/pages/DumpEdit.tsx:266
|
#: src/pages/DumpEdit.tsx:266
|
||||||
msgid "Tell the community what makes this worth their time..."
|
msgid "Tell the community what makes this worth their time..."
|
||||||
msgstr "Tell the community what makes this worth their time..."
|
msgstr "Tell the community what makes this worth their time..."
|
||||||
@@ -904,10 +1010,14 @@ msgstr "Tell the community what makes this worth their time..."
|
|||||||
msgid "This invite link is missing, expired, or already used."
|
msgid "This invite link is missing, expired, or already used."
|
||||||
msgstr "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."
|
msgid "This is a mirage."
|
||||||
msgstr "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
|
#: src/components/PlaylistCreateForm.tsx:72
|
||||||
msgid "Title"
|
msgid "Title"
|
||||||
msgstr "Title"
|
msgstr "Title"
|
||||||
@@ -932,11 +1042,16 @@ msgstr "Unfollow {targetUsername}"
|
|||||||
msgid "Unfollow playlist"
|
msgid "Unfollow playlist"
|
||||||
msgstr "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"
|
msgid "Upload failed"
|
||||||
msgstr "Upload failed"
|
msgstr "Upload failed"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:433
|
#: src/components/DumpCreateModal.tsx:426
|
||||||
msgid "Uploading…"
|
msgid "Uploading…"
|
||||||
msgstr "Uploading…"
|
msgstr "Uploading…"
|
||||||
|
|
||||||
@@ -946,16 +1061,16 @@ msgstr "Upvoted"
|
|||||||
|
|
||||||
#. placeholder {0}: votes.items.length
|
#. placeholder {0}: votes.items.length
|
||||||
#. placeholder {1}: votes.hasMore ? "+" : ""
|
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:998
|
#: src/pages/UserPublicProfile.tsx:1015
|
||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Upvoted ({0}{1})"
|
msgstr "Upvoted ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:332
|
#: src/components/DumpCreateModal.tsx:325
|
||||||
#: src/pages/DumpEdit.tsx:230
|
#: src/pages/DumpEdit.tsx:230
|
||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "URL"
|
msgstr "URL"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:185
|
#: src/components/DumpCreateModal.tsx:178
|
||||||
msgid "URL is required."
|
msgid "URL is required."
|
||||||
msgstr "URL is required."
|
msgstr "URL is required."
|
||||||
|
|
||||||
@@ -963,7 +1078,7 @@ msgstr "URL is required."
|
|||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
msgstr "User menu"
|
msgstr "User menu"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:74
|
#: src/pages/UserLogin.tsx:98
|
||||||
#: src/pages/UserRegister.tsx:129
|
#: src/pages/UserRegister.tsx:129
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "Username"
|
msgstr "Username"
|
||||||
@@ -980,19 +1095,19 @@ msgstr "Username"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Users"
|
msgstr "Users"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1062
|
#: src/pages/UserPublicProfile.tsx:1085
|
||||||
#: src/pages/UserPublicProfile.tsx:1100
|
#: src/pages/UserPublicProfile.tsx:1128
|
||||||
#: src/pages/UserPublicProfile.tsx:1137
|
#: src/pages/UserPublicProfile.tsx:1170
|
||||||
#: src/pages/UserPublicProfile.tsx:1313
|
#: src/pages/UserPublicProfile.tsx:1361
|
||||||
#: src/pages/UserPublicProfile.tsx:1445
|
#: src/pages/UserPublicProfile.tsx:1493
|
||||||
msgid "View all →"
|
msgid "View all →"
|
||||||
msgstr "View all →"
|
msgstr "View all →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:447
|
#: src/components/DumpCreateModal.tsx:440
|
||||||
msgid "View dump →"
|
msgid "View dump →"
|
||||||
msgstr "View dump →"
|
msgstr "View dump →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:383
|
#: src/components/DumpCreateModal.tsx:376
|
||||||
#: src/pages/DumpEdit.tsx:260
|
#: src/pages/DumpEdit.tsx:260
|
||||||
msgid "Why are you dumping this?"
|
msgid "Why are you dumping this?"
|
||||||
msgstr "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
|
#: src/pages/UserUpvoted.tsx:182
|
||||||
msgid "You've reached the end."
|
msgid "You've reached the end."
|
||||||
msgstr "You've reached the end."
|
msgstr "You've reached the end."
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:160
|
||||||
|
msgid "Your email address"
|
||||||
|
msgstr "Your email address"
|
||||||
|
|
||||||
|
#: src/pages/ResetPassword.tsx:49
|
||||||
|
msgid "Your password has been changed. You can now log in."
|
||||||
|
msgstr "Your password has been changed. You can now log in."
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -54,7 +54,7 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
|||||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:611
|
#: src/pages/PlaylistDetail.tsx:611
|
||||||
#: src/pages/UserPublicProfile.tsx:728
|
#: src/pages/UserPublicProfile.tsx:745
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Retour"
|
msgstr "← Retour"
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ msgstr "← Retour à toutes les recos"
|
|||||||
msgid "← Back to profile"
|
msgid "← Back to profile"
|
||||||
msgstr "← Retour au profil"
|
msgstr "← Retour au profil"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:93
|
#: src/pages/UserPublicProfile.tsx:100
|
||||||
msgid "+ Invite someone"
|
msgid "+ Invite someone"
|
||||||
msgstr "+ Inviter quelqu'un"
|
msgstr "+ Inviter quelqu'un"
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ msgid "+ New"
|
|||||||
msgstr "+ Nouveau"
|
msgstr "+ Nouveau"
|
||||||
|
|
||||||
#: src/pages/UserDumps.tsx:114
|
#: src/pages/UserDumps.tsx:114
|
||||||
#: src/pages/UserPublicProfile.tsx:1282
|
#: src/pages/UserPublicProfile.tsx:1330
|
||||||
msgid "+ New dump"
|
msgid "+ New dump"
|
||||||
msgstr "+ Nouvelle reco"
|
msgstr "+ Nouvelle reco"
|
||||||
|
|
||||||
@@ -134,7 +134,11 @@ msgstr "un commentaire"
|
|||||||
msgid "a post"
|
msgid "a post"
|
||||||
msgstr "une publication"
|
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…"
|
msgid "Add a bio…"
|
||||||
msgstr "Ajouter une bio…"
|
msgstr "Ajouter une bio…"
|
||||||
|
|
||||||
@@ -142,12 +146,12 @@ msgstr "Ajouter une bio…"
|
|||||||
msgid "Add a comment…"
|
msgid "Add a comment…"
|
||||||
msgstr "Ajouter un commentaire…"
|
msgstr "Ajouter un commentaire…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:842
|
#: src/pages/UserPublicProfile.tsx:859
|
||||||
msgid "Add email…"
|
msgid "Add email…"
|
||||||
msgstr "Ajouter un e-mail…"
|
msgstr "Ajouter un e-mail…"
|
||||||
|
|
||||||
#: src/components/AddToPlaylistModal.tsx:64
|
#: src/components/AddToPlaylistModal.tsx:64
|
||||||
#: src/components/DumpCreateModal.tsx:284
|
#: src/components/DumpCreateModal.tsx:277
|
||||||
msgid "Add to playlist"
|
msgid "Add to playlist"
|
||||||
msgstr "Ajouter à la collection"
|
msgstr "Ajouter à la collection"
|
||||||
|
|
||||||
@@ -163,29 +167,41 @@ msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} char
|
|||||||
msgid "Already have an account? <0>Log in</0>"
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1186
|
#: src/pages/UserPublicProfile.tsx:1234
|
||||||
msgid "Appearance"
|
msgid "Appearance"
|
||||||
msgstr "Apparence"
|
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"
|
msgid "Auto"
|
||||||
msgstr "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:168
|
||||||
#: src/contexts/WSProvider.tsx:360
|
#: src/contexts/WSProvider.tsx:360
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
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."
|
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:281
|
||||||
#: src/components/CommentThread.tsx:373
|
#: src/components/CommentThread.tsx:373
|
||||||
#: src/components/CommentThread.tsx:510
|
#: src/components/CommentThread.tsx:510
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
#: src/components/DumpCreateModal.tsx:422
|
#: src/components/DumpCreateModal.tsx:415
|
||||||
#: src/components/PlaylistCreateForm.tsx:112
|
#: src/components/PlaylistCreateForm.tsx:112
|
||||||
#: src/pages/DumpEdit.tsx:299
|
#: src/pages/DumpEdit.tsx:299
|
||||||
#: src/pages/PlaylistDetail.tsx:680
|
#: src/pages/PlaylistDetail.tsx:680
|
||||||
#: src/pages/UserPublicProfile.tsx:824
|
#: src/pages/UserPublicProfile.tsx:841
|
||||||
#: src/pages/UserPublicProfile.tsx:902
|
#: src/pages/UserPublicProfile.tsx:919
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Annuler"
|
msgstr "Annuler"
|
||||||
|
|
||||||
@@ -193,30 +209,54 @@ msgstr "Annuler"
|
|||||||
msgid "Cancel removal"
|
msgid "Cancel removal"
|
||||||
msgstr "Annuler la suppression"
|
msgstr "Annuler la suppression"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:755
|
#: src/pages/UserPublicProfile.tsx:772
|
||||||
msgid "Change avatar"
|
msgid "Change avatar"
|
||||||
msgstr "Changer l'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
|
#: src/pages/UserRegister.tsx:95
|
||||||
msgid "Checking invite…"
|
msgid "Checking invite…"
|
||||||
msgstr "Vérification de l'invitation…"
|
msgstr "Vérification de l'invitation…"
|
||||||
|
|
||||||
|
#: src/components/ChangePasswordModal.tsx:65
|
||||||
#: src/components/Modal.tsx:45
|
#: src/components/Modal.tsx:45
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Fermer"
|
msgstr "Fermer"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1212
|
#: src/pages/UserPublicProfile.tsx:1260
|
||||||
msgid "Color scheme"
|
msgid "Color scheme"
|
||||||
msgstr "Thème de couleur"
|
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!"
|
msgid "Copied!"
|
||||||
msgstr "Copié !"
|
msgstr "Copié !"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:84
|
#: src/pages/UserPublicProfile.tsx:91
|
||||||
msgid "Copy"
|
msgid "Copy"
|
||||||
msgstr "Copier"
|
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:111
|
||||||
#: src/components/CommentThread.tsx:153
|
#: src/components/CommentThread.tsx:153
|
||||||
#: src/components/CommentThread.tsx:448
|
#: src/components/CommentThread.tsx:448
|
||||||
@@ -241,7 +281,11 @@ msgstr "Créées ({0}{1})"
|
|||||||
msgid "Creating…"
|
msgid "Creating…"
|
||||||
msgstr "Création…"
|
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"
|
msgid "Dark"
|
||||||
msgstr "Sombre"
|
msgstr "Sombre"
|
||||||
|
|
||||||
@@ -281,7 +325,7 @@ msgstr "Supprimer cette collection ? Cette action est irréversible."
|
|||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
msgstr "Description (facultatif)"
|
msgstr "Description (facultatif)"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:468
|
#: src/components/DumpCreateModal.tsx:461
|
||||||
msgid "Done"
|
msgid "Done"
|
||||||
msgstr "Terminé"
|
msgstr "Terminé"
|
||||||
|
|
||||||
@@ -293,23 +337,23 @@ msgstr "Déposez un fichier ici"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Déposez un fichier de remplacement ici"
|
msgstr "Déposez un fichier de remplacement ici"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:434
|
#: src/components/DumpCreateModal.tsx:427
|
||||||
msgid "Dump it"
|
msgid "Dump it"
|
||||||
msgstr "Recommander"
|
msgstr "Recommander"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:445
|
#: src/components/DumpCreateModal.tsx:438
|
||||||
msgid "Dumped!"
|
msgid "Dumped!"
|
||||||
msgstr "Recommandé !"
|
msgstr "Recommandé !"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:172
|
#: src/pages/Search.tsx:172
|
||||||
#: src/pages/UserDumps.tsx:107
|
#: src/pages/UserDumps.tsx:107
|
||||||
#: src/pages/UserPublicProfile.tsx:950
|
#: src/pages/UserPublicProfile.tsx:967
|
||||||
msgid "Dumps"
|
msgid "Dumps"
|
||||||
msgstr "Recos"
|
msgstr "Recos"
|
||||||
|
|
||||||
#. placeholder {0}: dumps.items.length
|
#. placeholder {0}: dumps.items.length
|
||||||
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:987
|
#: src/pages/UserPublicProfile.tsx:1004
|
||||||
msgid "Dumps ({0}{1})"
|
msgid "Dumps ({0}{1})"
|
||||||
msgstr "Recos ({0}{1})"
|
msgstr "Recos ({0}{1})"
|
||||||
|
|
||||||
@@ -353,14 +397,18 @@ msgstr "Adresse e-mail"
|
|||||||
msgid "Enter a query to search."
|
msgid "Enter a query to search."
|
||||||
msgstr "Saisissez une recherche."
|
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:62
|
||||||
#: src/components/PlaylistCreateForm.tsx:103
|
#: src/components/PlaylistCreateForm.tsx:103
|
||||||
msgid "Failed to create playlist"
|
msgid "Failed to create playlist"
|
||||||
msgstr "Impossible de créer la collection"
|
msgstr "Impossible de créer la collection"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:65
|
#: src/pages/UserPublicProfile.tsx:72
|
||||||
#: src/pages/UserPublicProfile.tsx:68
|
#: src/pages/UserPublicProfile.tsx:75
|
||||||
#: src/pages/UserPublicProfile.tsx:96
|
#: src/pages/UserPublicProfile.tsx:103
|
||||||
msgid "Failed to generate invite"
|
msgid "Failed to generate invite"
|
||||||
msgstr "Impossible de générer une invitation"
|
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/JournalFeed.tsx:48
|
||||||
#: src/pages/index/NewFeed.tsx:36
|
#: src/pages/index/NewFeed.tsx:36
|
||||||
#: src/pages/Notifications.tsx:323
|
#: src/pages/Notifications.tsx:323
|
||||||
#: src/pages/UserPublicProfile.tsx:1081
|
#: src/pages/UserPublicProfile.tsx:1106
|
||||||
#: src/pages/UserPublicProfile.tsx:1118
|
#: src/pages/UserPublicProfile.tsx:1148
|
||||||
#: src/pages/UserPublicProfile.tsx:1160
|
#: src/pages/UserPublicProfile.tsx:1193
|
||||||
msgid "Failed to load"
|
msgid "Failed to load"
|
||||||
msgstr "Chargement échoué"
|
msgstr "Chargement échoué"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:322
|
#: src/components/DumpCreateModal.tsx:315
|
||||||
msgid "Failed to post"
|
msgid "Failed to post"
|
||||||
msgstr "Publication échouée"
|
msgstr "Publication échouée"
|
||||||
|
|
||||||
@@ -388,10 +436,10 @@ msgid "Failed to post reply"
|
|||||||
msgstr "Impossible de publier la réponse"
|
msgstr "Impossible de publier la réponse"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:789
|
#: src/pages/PlaylistDetail.tsx:789
|
||||||
#: src/pages/UserPublicProfile.tsx:663
|
#: src/pages/UserPublicProfile.tsx:680
|
||||||
#: src/pages/UserPublicProfile.tsx:701
|
#: src/pages/UserPublicProfile.tsx:718
|
||||||
#: src/pages/UserPublicProfile.tsx:828
|
#: src/pages/UserPublicProfile.tsx:845
|
||||||
#: src/pages/UserPublicProfile.tsx:905
|
#: src/pages/UserPublicProfile.tsx:922
|
||||||
msgid "Failed to save"
|
msgid "Failed to save"
|
||||||
msgstr "Enregistrement échoué"
|
msgstr "Enregistrement échoué"
|
||||||
|
|
||||||
@@ -399,24 +447,24 @@ msgstr "Enregistrement échoué"
|
|||||||
msgid "Failed to save edit"
|
msgid "Failed to save edit"
|
||||||
msgstr "Impossible d'enregistrer la modification"
|
msgstr "Impossible d'enregistrer la modification"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:851
|
#: src/pages/UserPublicProfile.tsx:868
|
||||||
msgid "Failed to update avatar"
|
msgid "Failed to update avatar"
|
||||||
msgstr "Impossible de mettre à jour l'avatar"
|
msgstr "Impossible de mettre à jour l'avatar"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:359
|
#: src/components/DumpCreateModal.tsx:352
|
||||||
msgid "Fetching preview…"
|
msgid "Fetching preview…"
|
||||||
msgstr "Récupération de l'aperçu…"
|
msgstr "Récupération de l'aperçu…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:432
|
#: src/components/DumpCreateModal.tsx:425
|
||||||
msgid "Fetching…"
|
msgid "Fetching…"
|
||||||
msgstr "Récupération…"
|
msgstr "Récupération…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:315
|
#: src/components/DumpCreateModal.tsx:308
|
||||||
#: src/components/FileDropZone.tsx:31
|
#: src/components/FileDropZone.tsx:31
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Fichier"
|
msgstr "Fichier"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:209
|
#: src/components/DumpCreateModal.tsx:202
|
||||||
msgid "File too large (max 50 MB)."
|
msgid "File too large (max 50 MB)."
|
||||||
msgstr "Fichier trop volumineux (max 50 Mo)."
|
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||||
|
|
||||||
@@ -442,7 +490,7 @@ msgid "Follow some users to see their dumps here."
|
|||||||
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:47
|
#: src/components/FeedTabBar.tsx:47
|
||||||
#: src/pages/UserPublicProfile.tsx:964
|
#: src/pages/UserPublicProfile.tsx:981
|
||||||
msgid "Followed"
|
msgid "Followed"
|
||||||
msgstr "Suivi"
|
msgstr "Suivi"
|
||||||
|
|
||||||
@@ -452,16 +500,20 @@ msgstr "Suivi"
|
|||||||
msgid "Followed ({0}{1})"
|
msgid "Followed ({0}{1})"
|
||||||
msgstr "Suivies ({0}{1})"
|
msgstr "Suivies ({0}{1})"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1109
|
#: src/pages/UserPublicProfile.tsx:1137
|
||||||
msgid "Followed playlists"
|
msgid "Followed playlists"
|
||||||
msgstr "Collections suivies"
|
msgstr "Collections suivies"
|
||||||
|
|
||||||
#: src/components/FollowButton.tsx:37
|
#: src/components/FollowButton.tsx:37
|
||||||
#: src/components/FollowButton.tsx:64
|
#: src/components/FollowButton.tsx:64
|
||||||
#: src/pages/UserPublicProfile.tsx:1072
|
#: src/pages/UserPublicProfile.tsx:1095
|
||||||
msgid "Following"
|
msgid "Following"
|
||||||
msgstr "Abonné"
|
msgstr "Abonné"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:131
|
||||||
|
msgid "Forgot password?"
|
||||||
|
msgstr "Mot de passe oublié ?"
|
||||||
|
|
||||||
#: src/pages/index/FollowedFeed.tsx:337
|
#: src/pages/index/FollowedFeed.tsx:337
|
||||||
msgid "From people"
|
msgid "From people"
|
||||||
msgstr "De personnes"
|
msgstr "De personnes"
|
||||||
@@ -470,20 +522,32 @@ msgstr "De personnes"
|
|||||||
msgid "From playlists"
|
msgid "From playlists"
|
||||||
msgstr "De collections"
|
msgstr "De collections"
|
||||||
|
|
||||||
|
#: src/pages/ResetPassword.tsx:56
|
||||||
|
msgid "Go to login"
|
||||||
|
msgstr "Aller à la connexion"
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:25
|
#: src/components/FeedTabBar.tsx:25
|
||||||
msgid "Hot"
|
msgid "Hot"
|
||||||
msgstr "Tendances"
|
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
|
#: src/pages/UserRegister.tsx:106
|
||||||
msgid "Invalid invite"
|
msgid "Invalid invite"
|
||||||
msgstr "Invitation invalide"
|
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"
|
msgid "invited by"
|
||||||
msgstr "invité par"
|
msgstr "invité par"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:971
|
#: src/pages/UserPublicProfile.tsx:988
|
||||||
#: src/pages/UserPublicProfile.tsx:1149
|
#: src/pages/UserPublicProfile.tsx:1182
|
||||||
msgid "Invitees"
|
msgid "Invitees"
|
||||||
msgstr "Invités"
|
msgstr "Invités"
|
||||||
|
|
||||||
@@ -495,7 +559,7 @@ msgstr "Journal"
|
|||||||
msgid "just now"
|
msgid "just now"
|
||||||
msgstr "à l'instant"
|
msgstr "à l'instant"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1227
|
#: src/pages/UserPublicProfile.tsx:1275
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Clair"
|
msgstr "Clair"
|
||||||
|
|
||||||
@@ -532,7 +596,7 @@ msgstr "Chargement…"
|
|||||||
msgid "Loading playlist…"
|
msgid "Loading playlist…"
|
||||||
msgstr "Chargement de la collection…"
|
msgstr "Chargement de la collection…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:711
|
#: src/pages/UserPublicProfile.tsx:728
|
||||||
msgid "Loading profile…"
|
msgid "Loading profile…"
|
||||||
msgstr "Chargement du profil…"
|
msgstr "Chargement du profil…"
|
||||||
|
|
||||||
@@ -546,29 +610,29 @@ msgstr "Chargement du profil…"
|
|||||||
#: src/pages/Notifications.tsx:395
|
#: src/pages/Notifications.tsx:395
|
||||||
#: src/pages/UserDumps.tsx:51
|
#: src/pages/UserDumps.tsx:51
|
||||||
#: src/pages/UserPlaylists.tsx:342
|
#: src/pages/UserPlaylists.tsx:342
|
||||||
#: src/pages/UserPublicProfile.tsx:1077
|
#: src/pages/UserPublicProfile.tsx:1100
|
||||||
#: src/pages/UserPublicProfile.tsx:1114
|
#: src/pages/UserPublicProfile.tsx:1142
|
||||||
#: src/pages/UserPublicProfile.tsx:1154
|
#: src/pages/UserPublicProfile.tsx:1187
|
||||||
#: src/pages/UserUpvoted.tsx:123
|
#: src/pages/UserUpvoted.tsx:123
|
||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Chargement…"
|
msgstr "Chargement…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:74
|
#: src/components/AppHeader.tsx:74
|
||||||
#: src/pages/UserLogin.tsx:63
|
#: src/pages/UserLogin.tsx:87
|
||||||
#: src/pages/UserLogin.tsx:93
|
#: src/pages/UserLogin.tsx:117
|
||||||
msgid "Log in"
|
msgid "Log in"
|
||||||
msgstr "Se connecter"
|
msgstr "Se connecter"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:732
|
#: src/pages/UserPublicProfile.tsx:749
|
||||||
#: src/pages/UserPublicProfile.tsx:865
|
#: src/pages/UserPublicProfile.tsx:882
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Se déconnecter"
|
msgstr "Se déconnecter"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:92
|
#: src/pages/UserLogin.tsx:116
|
||||||
msgid "Logging in…"
|
msgid "Logging in…"
|
||||||
msgstr "Connexion…"
|
msgstr "Connexion…"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:67
|
#: src/pages/UserLogin.tsx:91
|
||||||
msgid "Login failed"
|
msgid "Login failed"
|
||||||
msgstr "Connexion échouée"
|
msgstr "Connexion échouée"
|
||||||
|
|
||||||
@@ -584,10 +648,15 @@ msgstr "nouveau"
|
|||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "Nouveau"
|
msgstr "Nouveau"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:284
|
#: src/components/DumpCreateModal.tsx:277
|
||||||
msgid "New dump"
|
msgid "New dump"
|
||||||
msgstr "Nouvelle reco"
|
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
|
#: src/components/NewPlaylistForm.tsx:34
|
||||||
msgid "New playlist"
|
msgid "New playlist"
|
||||||
msgstr "Nouvelle collection"
|
msgstr "Nouvelle collection"
|
||||||
@@ -611,11 +680,11 @@ msgid "No emoji found."
|
|||||||
msgstr "Aucun emoji trouvé."
|
msgstr "Aucun emoji trouvé."
|
||||||
|
|
||||||
#: src/pages/UserPlaylists.tsx:439
|
#: src/pages/UserPlaylists.tsx:439
|
||||||
#: src/pages/UserPublicProfile.tsx:1122
|
#: src/pages/UserPublicProfile.tsx:1155
|
||||||
msgid "No followed playlists yet."
|
msgid "No followed playlists yet."
|
||||||
msgstr "Pas encore de collections suivies."
|
msgstr "Pas encore de collections suivies."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1167
|
#: src/pages/UserPublicProfile.tsx:1200
|
||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "Aucun invité pour le moment."
|
msgstr "Aucun invité pour le moment."
|
||||||
|
|
||||||
@@ -625,7 +694,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
|
|||||||
|
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:34
|
#: src/components/PlaylistMembershipPanel.tsx:34
|
||||||
#: src/pages/UserPlaylists.tsx:397
|
#: src/pages/UserPlaylists.tsx:397
|
||||||
#: src/pages/UserPublicProfile.tsx:1043
|
#: src/pages/UserPublicProfile.tsx:1066
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "Pas encore de collections."
|
msgstr "Pas encore de collections."
|
||||||
|
|
||||||
@@ -633,14 +702,14 @@ msgstr "Pas encore de collections."
|
|||||||
msgid "No users match \"{q}\"."
|
msgid "No users match \"{q}\"."
|
||||||
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1085
|
#: src/pages/UserPublicProfile.tsx:1113
|
||||||
msgid "Not following anyone yet."
|
msgid "Not following anyone yet."
|
||||||
msgstr "Aucun abonnement pour le moment."
|
msgstr "Aucun abonnement pour le moment."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:330
|
#: src/pages/Notifications.tsx:330
|
||||||
#: src/pages/UserDumps.tsx:123
|
#: src/pages/UserDumps.tsx:123
|
||||||
#: src/pages/UserPublicProfile.tsx:1292
|
#: src/pages/UserPublicProfile.tsx:1340
|
||||||
#: src/pages/UserPublicProfile.tsx:1415
|
#: src/pages/UserPublicProfile.tsx:1463
|
||||||
#: src/pages/UserUpvoted.tsx:195
|
#: src/pages/UserUpvoted.tsx:195
|
||||||
msgid "Nothing here yet."
|
msgid "Nothing here yet."
|
||||||
msgstr "Rien ici pour l'instant."
|
msgstr "Rien ici pour l'instant."
|
||||||
@@ -662,7 +731,8 @@ msgstr "Ouvrir la recherche"
|
|||||||
msgid "or <0>browse files</0>"
|
msgid "or <0>browse files</0>"
|
||||||
msgstr "ou <0>parcourir les fichiers</0>"
|
msgstr "ou <0>parcourir les fichiers</0>"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:82
|
#: src/pages/UserLogin.tsx:106
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1220
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Mot de passe"
|
msgstr "Mot de passe"
|
||||||
|
|
||||||
@@ -671,21 +741,34 @@ msgstr "Mot de passe"
|
|||||||
msgid "Password (min. {0} characters)"
|
msgid "Password (min. {0} characters)"
|
||||||
msgstr "Mot de passe (min. {0} caractères)"
|
msgstr "Mot de passe (min. {0} caractères)"
|
||||||
|
|
||||||
|
#: src/components/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/AppHeader.tsx:50
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:175
|
#: src/pages/Search.tsx:175
|
||||||
#: src/pages/UserPlaylists.tsx:368
|
#: src/pages/UserPlaylists.tsx:368
|
||||||
#: src/pages/UserPublicProfile.tsx:957
|
#: src/pages/UserPublicProfile.tsx:974
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
msgstr "Collections"
|
msgstr "Collections"
|
||||||
|
|
||||||
#. placeholder {0}: playlists.items.length
|
#. placeholder {0}: playlists.items.length
|
||||||
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:1016
|
#: src/pages/UserPublicProfile.tsx:1035
|
||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Collections ({0}{1})"
|
msgstr "Collections ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:202
|
#: src/components/DumpCreateModal.tsx:195
|
||||||
msgid "Please select a file."
|
msgid "Please select a file."
|
||||||
msgstr "Veuillez sélectionner un fichier."
|
msgstr "Veuillez sélectionner un fichier."
|
||||||
|
|
||||||
@@ -710,7 +793,7 @@ msgstr "Publication…"
|
|||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "privé"
|
msgstr "privé"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:411
|
#: src/components/DumpCreateModal.tsx:404
|
||||||
#: src/components/PlaylistCreateForm.tsx:99
|
#: src/components/PlaylistCreateForm.tsx:99
|
||||||
#: src/pages/DumpEdit.tsx:285
|
#: src/pages/DumpEdit.tsx:285
|
||||||
#: src/pages/PlaylistDetail.tsx:746
|
#: src/pages/PlaylistDetail.tsx:746
|
||||||
@@ -722,7 +805,7 @@ msgstr "Privé"
|
|||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:403
|
#: src/components/DumpCreateModal.tsx:396
|
||||||
#: src/components/PlaylistCreateForm.tsx:92
|
#: src/components/PlaylistCreateForm.tsx:92
|
||||||
#: src/pages/DumpEdit.tsx:278
|
#: src/pages/DumpEdit.tsx:278
|
||||||
#: src/pages/PlaylistDetail.tsx:739
|
#: src/pages/PlaylistDetail.tsx:739
|
||||||
@@ -766,6 +849,14 @@ msgstr "Remplacer le fichier"
|
|||||||
msgid "Reply"
|
msgid "Reply"
|
||||||
msgstr "Répondre"
|
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/Dump.tsx:211
|
||||||
#: src/pages/DumpEdit.tsx:163
|
#: src/pages/DumpEdit.tsx:163
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
@@ -774,15 +865,17 @@ msgstr "Réessayer"
|
|||||||
#: src/components/CommentThread.tsx:270
|
#: src/components/CommentThread.tsx:270
|
||||||
#: src/pages/DumpEdit.tsx:306
|
#: src/pages/DumpEdit.tsx:306
|
||||||
#: src/pages/PlaylistDetail.tsx:673
|
#: src/pages/PlaylistDetail.tsx:673
|
||||||
#: src/pages/UserPublicProfile.tsx:816
|
#: src/pages/UserPublicProfile.tsx:833
|
||||||
#: src/pages/UserPublicProfile.tsx:894
|
#: src/pages/UserPublicProfile.tsx:911
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Enregistrer"
|
msgstr "Enregistrer"
|
||||||
|
|
||||||
|
#: src/components/ChangePasswordModal.tsx:141
|
||||||
#: src/components/CommentThread.tsx:269
|
#: src/components/CommentThread.tsx:269
|
||||||
#: src/pages/PlaylistDetail.tsx:673
|
#: src/pages/PlaylistDetail.tsx:673
|
||||||
#: src/pages/UserPublicProfile.tsx:815
|
#: src/pages/ResetPassword.tsx:140
|
||||||
#: src/pages/UserPublicProfile.tsx:894
|
#: src/pages/UserPublicProfile.tsx:832
|
||||||
|
#: src/pages/UserPublicProfile.tsx:911
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Enregistrement…"
|
msgstr "Enregistrement…"
|
||||||
|
|
||||||
@@ -802,11 +895,24 @@ msgstr "Recherche échouée"
|
|||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Recherche…"
|
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
|
#: src/components/AppHeader.tsx:65
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Serveur inaccessible"
|
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"
|
msgid "Settings"
|
||||||
msgstr "Paramètres"
|
msgstr "Paramètres"
|
||||||
|
|
||||||
@@ -814,7 +920,7 @@ msgstr "Paramètres"
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Une erreur est survenue"
|
msgstr "Une erreur est survenue"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1191
|
#: src/pages/UserPublicProfile.tsx:1239
|
||||||
msgid "Style"
|
msgid "Style"
|
||||||
msgstr "Style"
|
msgstr "Style"
|
||||||
|
|
||||||
@@ -822,11 +928,11 @@ msgstr "Style"
|
|||||||
msgid "Submit search"
|
msgid "Submit search"
|
||||||
msgstr "Lancer la recherche"
|
msgstr "Lancer la recherche"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:882
|
#: src/pages/UserPublicProfile.tsx:899
|
||||||
msgid "Tell people about yourself…"
|
msgid "Tell people about yourself…"
|
||||||
msgstr "Parlez de vous…"
|
msgstr "Parlez de vous…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:390
|
#: src/components/DumpCreateModal.tsx:383
|
||||||
#: src/pages/DumpEdit.tsx:266
|
#: src/pages/DumpEdit.tsx:266
|
||||||
msgid "Tell the community what makes this worth their time..."
|
msgid "Tell the community what makes this worth their time..."
|
||||||
msgstr "Dites à la communauté pourquoi ça vaut le coup…"
|
msgstr "Dites à la communauté pourquoi ça vaut le coup…"
|
||||||
@@ -835,10 +941,14 @@ msgstr "Dites à la communauté pourquoi ça vaut le coup…"
|
|||||||
msgid "This invite link is missing, expired, or already used."
|
msgid "This invite link is missing, expired, or already used."
|
||||||
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
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."
|
msgid "This is a mirage."
|
||||||
msgstr "C'est un 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
|
#: src/components/PlaylistCreateForm.tsx:72
|
||||||
msgid "Title"
|
msgid "Title"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
@@ -859,11 +969,16 @@ msgstr "Ne plus suivre {targetUsername}"
|
|||||||
msgid "Unfollow playlist"
|
msgid "Unfollow playlist"
|
||||||
msgstr "Ne plus suivre la collection"
|
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"
|
msgid "Upload failed"
|
||||||
msgstr "Envoi échoué"
|
msgstr "Envoi échoué"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:433
|
#: src/components/DumpCreateModal.tsx:426
|
||||||
msgid "Uploading…"
|
msgid "Uploading…"
|
||||||
msgstr "Envoi…"
|
msgstr "Envoi…"
|
||||||
|
|
||||||
@@ -873,16 +988,16 @@ msgstr "Voté"
|
|||||||
|
|
||||||
#. placeholder {0}: votes.items.length
|
#. placeholder {0}: votes.items.length
|
||||||
#. placeholder {1}: votes.hasMore ? "+" : ""
|
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:998
|
#: src/pages/UserPublicProfile.tsx:1015
|
||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Votés ({0}{1})"
|
msgstr "Votés ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:332
|
#: src/components/DumpCreateModal.tsx:325
|
||||||
#: src/pages/DumpEdit.tsx:230
|
#: src/pages/DumpEdit.tsx:230
|
||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "URL"
|
msgstr "URL"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:185
|
#: src/components/DumpCreateModal.tsx:178
|
||||||
msgid "URL is required."
|
msgid "URL is required."
|
||||||
msgstr "L'URL est obligatoire."
|
msgstr "L'URL est obligatoire."
|
||||||
|
|
||||||
@@ -890,7 +1005,7 @@ msgstr "L'URL est obligatoire."
|
|||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
msgstr "Menu utilisateur"
|
msgstr "Menu utilisateur"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:74
|
#: src/pages/UserLogin.tsx:98
|
||||||
#: src/pages/UserRegister.tsx:129
|
#: src/pages/UserRegister.tsx:129
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "Nom d'utilisateur"
|
msgstr "Nom d'utilisateur"
|
||||||
@@ -899,19 +1014,19 @@ msgstr "Nom d'utilisateur"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Utilisateurs"
|
msgstr "Utilisateurs"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1062
|
#: src/pages/UserPublicProfile.tsx:1085
|
||||||
#: src/pages/UserPublicProfile.tsx:1100
|
#: src/pages/UserPublicProfile.tsx:1128
|
||||||
#: src/pages/UserPublicProfile.tsx:1137
|
#: src/pages/UserPublicProfile.tsx:1170
|
||||||
#: src/pages/UserPublicProfile.tsx:1313
|
#: src/pages/UserPublicProfile.tsx:1361
|
||||||
#: src/pages/UserPublicProfile.tsx:1445
|
#: src/pages/UserPublicProfile.tsx:1493
|
||||||
msgid "View all →"
|
msgid "View all →"
|
||||||
msgstr "Tout voir →"
|
msgstr "Tout voir →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:447
|
#: src/components/DumpCreateModal.tsx:440
|
||||||
msgid "View dump →"
|
msgid "View dump →"
|
||||||
msgstr "Voir la reco →"
|
msgstr "Voir la reco →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:383
|
#: src/components/DumpCreateModal.tsx:376
|
||||||
#: src/pages/DumpEdit.tsx:260
|
#: src/pages/DumpEdit.tsx:260
|
||||||
msgid "Why are you dumping this?"
|
msgid "Why are you dumping this?"
|
||||||
msgstr "Pourquoi recommandez-vous ça ?"
|
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
|
#: src/pages/UserUpvoted.tsx:182
|
||||||
msgid "You've reached the end."
|
msgid "You've reached the end."
|
||||||
msgstr "Vous avez tout lu, tout vu, tout bu."
|
msgstr "Vous avez tout lu, tout vu, tout bu."
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:160
|
||||||
|
msgid "Your email address"
|
||||||
|
msgstr "Votre adresse e-mail"
|
||||||
|
|
||||||
|
#: src/pages/ResetPassword.tsx:49
|
||||||
|
msgid "Your password has been changed. You can now log in."
|
||||||
|
msgstr "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."
|
||||||
|
|||||||
165
src/pages/ResetPassword.tsx
Normal file
165
src/pages/ResetPassword.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||||
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "submitting" }
|
||||||
|
| { status: "done" }
|
||||||
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
|
export function ResetPassword() {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const token = params.get("token") ?? "";
|
||||||
|
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [state, setState] = useState<State>({ status: "idle" });
|
||||||
|
|
||||||
|
const mismatch = confirm.length > 0 && newPassword !== confirm;
|
||||||
|
const tooShort = newPassword.length > 0 &&
|
||||||
|
newPassword.length < VALIDATION.PASSWORD_MIN;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<PageShell centered>
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-card-title">
|
||||||
|
<Trans>Invalid link</Trans>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
<Trans>This reset link is missing or malformed.</Trans>
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ marginTop: "1rem", display: "inline-block" }}
|
||||||
|
>
|
||||||
|
<Trans>Back to login</Trans>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "done") {
|
||||||
|
return (
|
||||||
|
<PageShell centered>
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-card-title">
|
||||||
|
<Trans>Password updated</Trans>
|
||||||
|
</h1>
|
||||||
|
<p style={{ marginBottom: "1rem" }}>
|
||||||
|
<Trans>Your password has been changed. You can now log in.</Trans>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
>
|
||||||
|
<Trans>Go to login</Trans>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (mismatch || tooShort || !newPassword) return;
|
||||||
|
|
||||||
|
setState({ status: "submitting" });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/users/reset-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, newPassword }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.success) {
|
||||||
|
setState({ status: "done" });
|
||||||
|
} else {
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: body.error?.message ?? t`Unknown error`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState({ status: "error", error: t`Could not connect to server` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell centered>
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-card-title">
|
||||||
|
<Trans>Set new password</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{state.status === "error" && (
|
||||||
|
<ErrorCard title={t`Reset failed`} message={state.error} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder={t`New password`}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={VALIDATION.PASSWORD_MIN}
|
||||||
|
maxLength={VALIDATION.PASSWORD_MAX}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={state.status === "submitting"}
|
||||||
|
/>
|
||||||
|
{tooShort && (
|
||||||
|
<span className="auth-field-hint auth-field-hint--error">
|
||||||
|
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder={t`Confirm new password`}
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
disabled={state.status === "submitting"}
|
||||||
|
/>
|
||||||
|
{mismatch && (
|
||||||
|
<span className="auth-field-hint auth-field-hint--error">
|
||||||
|
<Trans>Passwords do not match</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={state.status === "submitting" || mismatch || tooShort ||
|
||||||
|
!newPassword || !confirm}
|
||||||
|
>
|
||||||
|
{state.status === "submitting"
|
||||||
|
? <Trans>Saving…</Trans>
|
||||||
|
: <Trans>Set new password</Trans>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-card-footer">
|
||||||
|
<Link to="/login">
|
||||||
|
<Trans>Back to login</Trans>
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,21 +16,29 @@ import { PageShell } from "../components/PageShell.tsx";
|
|||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
|
|
||||||
type UserLoginState =
|
type LoginState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
| { status: "submitting" }
|
| { status: "submitting" }
|
||||||
| { status: "error"; error: string };
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
|
type ResetState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "submitting" }
|
||||||
|
| { status: "sent" }
|
||||||
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
export function UserLogin() {
|
export function UserLogin() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
|
||||||
const [state, setState] = useState<UserLoginState>({ status: "idle" });
|
const [loginState, setLoginState] = useState<LoginState>({ status: "idle" });
|
||||||
|
const [showReset, setShowReset] = useState(false);
|
||||||
|
const [resetEmail, setResetEmail] = useState("");
|
||||||
|
const [resetState, setResetState] = useState<ResetState>({ status: "idle" });
|
||||||
|
|
||||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setLoginState({ status: "submitting" });
|
||||||
setState({ status: "submitting" });
|
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const username = formData.get("username") as string;
|
const username = formData.get("username") as string;
|
||||||
@@ -49,10 +57,26 @@ export function UserLogin() {
|
|||||||
login(deserializeAuthResponse(apiResponse.data));
|
login(deserializeAuthResponse(apiResponse.data));
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} else {
|
} else {
|
||||||
setState({ status: "error", error: apiResponse.error.message });
|
setLoginState({ status: "error", error: apiResponse.error.message });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
setLoginState({ status: "error", error: friendlyFetchError(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetRequest = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!resetEmail.trim()) return;
|
||||||
|
setResetState({ status: "submitting" });
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/api/users/request-password-reset`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: resetEmail.trim() }),
|
||||||
|
});
|
||||||
|
setResetState({ status: "sent" });
|
||||||
|
} catch {
|
||||||
|
setResetState({ status: "error", error: t`Could not connect to server` });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,8 +87,8 @@ export function UserLogin() {
|
|||||||
<Trans>Log in</Trans>
|
<Trans>Log in</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{state.status === "error" && (
|
{loginState.status === "error" && (
|
||||||
<ErrorCard title={t`Login failed`} message={state.error} />
|
<ErrorCard title={t`Login failed`} message={loginState.error} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
@@ -73,7 +97,7 @@ export function UserLogin() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder={t`Username`}
|
placeholder={t`Username`}
|
||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={loginState.status === "submitting"}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -81,19 +105,81 @@ export function UserLogin() {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder={t`Password`}
|
placeholder={t`Password`}
|
||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={loginState.status === "submitting"}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={state.status === "submitting"}
|
disabled={loginState.status === "submitting"}
|
||||||
>
|
>
|
||||||
{state.status === "submitting"
|
{loginState.status === "submitting"
|
||||||
? <Trans>Logging in…</Trans>
|
? <Trans>Logging in…</Trans>
|
||||||
: <Trans>Log in</Trans>}
|
: <Trans>Log in</Trans>}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-card-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-link-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setShowReset((v) => !v);
|
||||||
|
setResetState({ status: "idle" });
|
||||||
|
setResetEmail("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Forgot password?</Trans>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showReset && (
|
||||||
|
<div className="auth-reset-panel">
|
||||||
|
{resetState.status === "sent"
|
||||||
|
? (
|
||||||
|
<p className="auth-reset-sent">
|
||||||
|
<Trans>
|
||||||
|
If that address is registered you'll receive a reset link
|
||||||
|
shortly.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
{resetState.status === "error" && (
|
||||||
|
<ErrorCard
|
||||||
|
title={t`Request failed`}
|
||||||
|
message={resetState.error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
onSubmit={handleResetRequest}
|
||||||
|
className="auth-form auth-reset-form"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder={t`Your email address`}
|
||||||
|
value={resetEmail}
|
||||||
|
onChange={(e) => setResetEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={resetState.status === "submitting"}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={resetState.status === "submitting" ||
|
||||||
|
!resetEmail.trim()}
|
||||||
|
>
|
||||||
|
{resetState.status === "submitting"
|
||||||
|
? <Trans>Sending…</Trans>
|
||||||
|
: <Trans>Send reset link</Trans>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="auth-card-footer">
|
<p className="auth-card-footer">
|
||||||
<Trans>This is a mirage.</Trans>
|
<Trans>This is a mirage.</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { ErrorCard } from "../components/ErrorCard.tsx";
|
|||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
import { TextEditor } from "../components/TextEditor.tsx";
|
import { TextEditor } from "../components/TextEditor.tsx";
|
||||||
import { Markdown } from "../components/Markdown.tsx";
|
import { Markdown } from "../components/Markdown.tsx";
|
||||||
|
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
|
||||||
|
|
||||||
function InviteButton() {
|
function InviteButton() {
|
||||||
const { authFetch } = useAuth();
|
const { authFetch } = useAuth();
|
||||||
@@ -284,6 +285,7 @@ export function UserPublicProfile() {
|
|||||||
const [tab, setTab] = useState<
|
const [tab, setTab] = useState<
|
||||||
"dumps" | "playlists" | "followed" | "invitees" | "settings"
|
"dumps" | "playlists" | "followed" | "invitees" | "settings"
|
||||||
>("dumps");
|
>("dumps");
|
||||||
|
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||||
const [followedState, setFollowedState] = useState<FollowedState>(null);
|
const [followedState, setFollowedState] = useState<FollowedState>(null);
|
||||||
const [inviteTreeState, setInviteTreeState] = useState<InviteTreeState>(null);
|
const [inviteTreeState, setInviteTreeState] = useState<InviteTreeState>(null);
|
||||||
|
|
||||||
@@ -1202,8 +1204,31 @@ export function UserPublicProfile() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{changePasswordOpen && (
|
||||||
|
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "settings" && isOwnProfile && (
|
{tab === "settings" && isOwnProfile && (
|
||||||
<>
|
<>
|
||||||
|
<section className="profile-section">
|
||||||
|
<h2 className="profile-section-title">
|
||||||
|
<Trans>Account</Trans>
|
||||||
|
</h2>
|
||||||
|
<div className="profile-appearance-grid">
|
||||||
|
<div className="profile-appearance-row">
|
||||||
|
<span className="profile-appearance-label">
|
||||||
|
<Trans>Password</Trans>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost"
|
||||||
|
onClick={() => setChangePasswordOpen(true)}
|
||||||
|
>
|
||||||
|
<Trans>Change password…</Trans>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section className="profile-section">
|
<section className="profile-section">
|
||||||
<h2 className="profile-section-title">
|
<h2 className="profile-section-title">
|
||||||
<Trans>Appearance</Trans>
|
<Trans>Appearance</Trans>
|
||||||
|
|||||||
@@ -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 {
|
export function dumpUrl(dump: { id: string; slug?: string }): string {
|
||||||
return `/dumps/${dump.slug ?? dump.id}`;
|
return `/dumps/${dump.slug ?? dump.id}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user