v3: fixed roles propagation bug, fixed inconsistent file drop zone, fixed chat attachments wrongfully pruned, some visual tweaks
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -46,13 +46,17 @@ export async function authMiddleware<R extends string>(
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
getUserById(payload.userId);
|
||||
user = getUserById(payload.userId);
|
||||
} catch {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "User not found");
|
||||
}
|
||||
|
||||
ctx.state.user = payload;
|
||||
// Trust the live role from the DB, not the role baked into the JWT at login.
|
||||
// Tokens last 7 days, so a role change (e.g. promotion to admin or a demotion)
|
||||
// would otherwise not take effect until the user re-logs in.
|
||||
ctx.state.user = { ...payload, role: user.role };
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
@@ -64,16 +64,16 @@ router.get("/ws", async (ctx) => {
|
||||
|
||||
const socket = ctx.upgrade();
|
||||
|
||||
const avatarMime = authPayload
|
||||
? getUserById(authPayload.userId).avatarMime
|
||||
: undefined;
|
||||
// Read the live user so the role reflects the DB, not the (possibly stale)
|
||||
// role embedded in the 7-day JWT.
|
||||
const user = authPayload ? getUserById(authPayload.userId) : undefined;
|
||||
|
||||
const client: WsClient = {
|
||||
socket,
|
||||
userId: authPayload?.userId,
|
||||
username: authPayload?.username,
|
||||
role: authPayload?.role,
|
||||
avatarMime,
|
||||
role: user?.role,
|
||||
avatarMime: user?.avatarMime,
|
||||
};
|
||||
|
||||
// Use addEventListener — more reliable than onopen= with Deno.serve
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ChatMessage,
|
||||
} from "../model/interfaces.ts";
|
||||
import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
|
||||
export const MAX_CHAT_LENGTH = 2000;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
@@ -70,6 +71,9 @@ export function createChatMessage(
|
||||
db.prepare(
|
||||
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
|
||||
).run(id, userId, trimmed, createdAt, replyTo);
|
||||
// Claim any pasted/dropped image attachments so the startup cleanup doesn't
|
||||
// purge them as orphans (mirrors dumps/comments/playlists).
|
||||
linkAttachments(trimmed, id);
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
@@ -115,6 +119,8 @@ export function updateChatMessage(
|
||||
}
|
||||
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
|
||||
.run(trimmed, new Date().toISOString(), id);
|
||||
// An edit may introduce a freshly pasted image; claim its attachment too.
|
||||
linkAttachments(trimmed, id);
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
|
||||
119
src/App.css
119
src/App.css
@@ -40,7 +40,8 @@
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.md ul, .md ol {
|
||||
.md ul,
|
||||
.md ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
@@ -53,7 +54,12 @@
|
||||
padding: 0.2em 0.75em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 {
|
||||
.md h1,
|
||||
.md h2,
|
||||
.md h3,
|
||||
.md h4,
|
||||
.md h5,
|
||||
.md h6 {
|
||||
margin: 0.6em 0 0.2em;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
@@ -466,17 +472,6 @@
|
||||
}
|
||||
|
||||
/* ── FileDropZone ── */
|
||||
.fdz-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.fdz-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fdz {
|
||||
border: 2px dashed var(--color-border-subtle);
|
||||
border-radius: 10px;
|
||||
@@ -1092,13 +1087,16 @@ body.has-player {
|
||||
edge. The lane width is `--fab-lane` (860px for most feeds; the journal
|
||||
grid is wider, see below). Falls back to a 1.25rem viewport gutter once
|
||||
that margin is too narrow to hold the button. */
|
||||
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem));
|
||||
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) -
|
||||
1rem));
|
||||
/* Anchored to the bottom-right by default, but expressed via `top` (rather
|
||||
than `bottom`) so the jump to the top-right when a player mounts can
|
||||
animate as a slide. `dvh` tracks the visible viewport on mobile; `vh` is
|
||||
the fallback for browsers without it. */
|
||||
top: calc(100vh - var(--fab-size) - 1.25rem - env(safe-area-inset-bottom, 0px));
|
||||
top: calc(100dvh - var(--fab-size) - 1.25rem - env(safe-area-inset-bottom, 0px));
|
||||
top: calc(100vh - var(--fab-size) - 1.25rem - env(safe-area-inset-bottom,
|
||||
0px));
|
||||
top: calc(100dvh - var(--fab-size) - 1.25rem - env(safe-area-inset-bottom,
|
||||
0px));
|
||||
z-index: 900;
|
||||
width: var(--fab-size);
|
||||
height: var(--fab-size);
|
||||
@@ -1112,7 +1110,8 @@ body.has-player {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%, transparent),
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%,
|
||||
transparent),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
/* Hidden state: faded out, nudged down, non-interactive. */
|
||||
opacity: 0;
|
||||
@@ -1136,7 +1135,8 @@ body.has-player {
|
||||
.dump-fab:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-2px) scale(1);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%, transparent),
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%,
|
||||
transparent),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@@ -1169,10 +1169,13 @@ body.has-fab .page-content {
|
||||
position: fixed;
|
||||
--fab-size: 3.5rem;
|
||||
/* Share the dump-fab's column. */
|
||||
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem));
|
||||
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) -
|
||||
1rem));
|
||||
/* One button-height + gap above the dump-fab's bottom-anchored resting spot. */
|
||||
top: calc(100vh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom, 0px));
|
||||
top: calc(100dvh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom, 0px));
|
||||
top: calc(100vh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom,
|
||||
0px));
|
||||
top: calc(100dvh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom,
|
||||
0px));
|
||||
z-index: 900;
|
||||
width: var(--fab-size);
|
||||
height: var(--fab-size);
|
||||
@@ -1185,7 +1188,8 @@ body.has-fab .page-content {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%, transparent),
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%,
|
||||
transparent),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transform: translateY(1rem) scale(0.9);
|
||||
@@ -1208,7 +1212,8 @@ body.has-fab .page-content {
|
||||
.chat-fab:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-2px) scale(1);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%, transparent),
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%,
|
||||
transparent),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@@ -1220,8 +1225,10 @@ body.has-fab .page-content {
|
||||
body.has-player .chat-fab {
|
||||
top: calc(1.25rem + env(safe-area-inset-top, 0px));
|
||||
/* Stack below the dump-fab in the top-right corner to avoid overlap. */
|
||||
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem));
|
||||
top: calc(1.25rem + var(--fab-size) + 0.75rem + env(safe-area-inset-top, 0px));
|
||||
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) -
|
||||
1rem));
|
||||
top: calc(1.25rem + var(--fab-size) + 0.75rem + env(safe-area-inset-top,
|
||||
0px));
|
||||
}
|
||||
|
||||
/* The badge sits on the circular FAB; nudge it onto the rim. */
|
||||
@@ -2990,7 +2997,8 @@ body.has-player .chat-fab {
|
||||
.dump-card-inner:hover .dump-card-preview,
|
||||
.playlist-card-inner:hover .playlist-card-preview {
|
||||
transform: scale(1.08);
|
||||
box-shadow: inset 0 0 0 1px var(--color-border), 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: inset 0 0 0 1px var(--color-border), 0 4px 12px rgba(0, 0, 0,
|
||||
0.25);
|
||||
}
|
||||
|
||||
/* ── Shared card preview icon ── */
|
||||
@@ -3681,7 +3689,9 @@ body.has-player .chat-fab {
|
||||
}
|
||||
|
||||
@keyframes chat-typing-bounce {
|
||||
0%, 60%, 100% {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -3825,7 +3835,6 @@ body.has-player .chat-fab {
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
|
||||
/* ── Playlist detail page ── */
|
||||
.playlist-detail-header {
|
||||
background: var(--color-surface);
|
||||
@@ -4092,6 +4101,60 @@ body.has-player .chat-fab {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* Backlinks are supporting context, not the main event — render them as a
|
||||
tighter, lighter list so they stay scannable but clearly subordinate to
|
||||
the dump's own content. */
|
||||
.related-section .related-section-title {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.related-section .dump-feed {
|
||||
padding: 0;
|
||||
gap: 0.4rem;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.related-section .dump-card {
|
||||
border-width: 1px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
opacity: 0.78;
|
||||
transition: border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.related-section .dump-card:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.related-section .dump-card-inner {
|
||||
padding: 0.45rem 0.65rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.related-section .dump-card-preview {
|
||||
width: 76px;
|
||||
height: 43px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.related-section .dump-card-title {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* Keep just enough description to orient, but only a single muted line. */
|
||||
.related-section .dump-card-comment {
|
||||
font-size: 0.78rem;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 512px) {
|
||||
.related-section .dump-card-preview {
|
||||
width: 64px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
@@ -12,10 +11,7 @@ import type {
|
||||
RawDump,
|
||||
RawPlaylistMembership,
|
||||
} from "../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePlaylistMembership,
|
||||
} from "../model.ts";
|
||||
import { deserializeDump, deserializePlaylistMembership } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { dumpUrl, normalizeUrl } from "../utils/urls.ts";
|
||||
@@ -23,12 +19,12 @@ import { MAX_FILE_SIZE } from "../config/upload.ts";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { CategorySelect } from "./CategorySelect.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FileField,
|
||||
FormActions,
|
||||
FormError,
|
||||
FormProvider,
|
||||
@@ -113,7 +109,7 @@ export function DumpCreateModal(
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
const { register, setValue, watch, clearErrors, control } = form;
|
||||
const { register, setValue, watch, clearErrors } = form;
|
||||
const submitting = form.formState.isSubmitting;
|
||||
const url = watch("url");
|
||||
const file = watch("file");
|
||||
@@ -381,16 +377,10 @@ export function DumpCreateModal(
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Controller
|
||||
<FileField<CreateValues>
|
||||
name="file"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FileDropZone
|
||||
file={field.value}
|
||||
onChange={selectFile}
|
||||
disabled={submitting}
|
||||
/>
|
||||
)}
|
||||
onValueChange={selectFile}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{file && <LocalFilePreview file={file} />}
|
||||
{file && (
|
||||
|
||||
@@ -15,7 +15,6 @@ interface FileDropZoneProps {
|
||||
file: File | null;
|
||||
onChange: (file: File | null) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
showLimit?: boolean;
|
||||
}
|
||||
@@ -24,11 +23,9 @@ export function FileDropZone({
|
||||
file,
|
||||
onChange,
|
||||
disabled,
|
||||
label,
|
||||
hint,
|
||||
showLimit = true,
|
||||
}: FileDropZoneProps) {
|
||||
const resolvedLabel = label ?? t`File`;
|
||||
const resolvedHint = hint ?? t`Drop a file here`;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -72,82 +69,79 @@ export function FileDropZone({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fdz-wrapper">
|
||||
{resolvedLabel && <span className="fdz-label">{resolvedLabel}</span>}
|
||||
<div
|
||||
className={`fdz${dragging ? " fdz--drag" : ""}${
|
||||
disabled ? " fdz--disabled" : ""
|
||||
}${file ? " fdz--filled" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={file ? undefined : handleClick}
|
||||
role={file ? undefined : "button"}
|
||||
tabIndex={file || disabled ? undefined : 0}
|
||||
onKeyDown={file || disabled ? undefined : (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
|
||||
disabled={disabled}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div
|
||||
className={`fdz${dragging ? " fdz--drag" : ""}${
|
||||
disabled ? " fdz--disabled" : ""
|
||||
}${file ? " fdz--filled" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={file ? undefined : handleClick}
|
||||
role={file ? undefined : "button"}
|
||||
tabIndex={file || disabled ? undefined : 0}
|
||||
onKeyDown={file || disabled ? undefined : (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
|
||||
disabled={disabled}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{file
|
||||
? (
|
||||
<div className="fdz__file">
|
||||
<span className="fdz__file-icon">{fileIcon(file.type)}</span>
|
||||
<div className="fdz__file-meta">
|
||||
<span className="fdz__file-name">{file.name}</span>
|
||||
<span className="fdz__file-size">{formatBytes(file.size)}</span>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="fdz__clear"
|
||||
onClick={handleClear}
|
||||
aria-label={t`Remove file`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{file
|
||||
? (
|
||||
<div className="fdz__file">
|
||||
<span className="fdz__file-icon">{fileIcon(file.type)}</span>
|
||||
<div className="fdz__file-meta">
|
||||
<span className="fdz__file-name">{file.name}</span>
|
||||
<span className="fdz__file-size">{formatBytes(file.size)}</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="fdz__empty">
|
||||
<svg
|
||||
className="fdz__upload-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="fdz__clear"
|
||||
onClick={handleClear}
|
||||
aria-label={t`Remove file`}
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<p className="fdz__hint">{resolvedHint}</p>
|
||||
<p className="fdz__browse">
|
||||
<Trans>
|
||||
or <span className="fdz__browse-link">browse files</span>
|
||||
</Trans>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="fdz__empty">
|
||||
<svg
|
||||
className="fdz__upload-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<p className="fdz__hint">{resolvedHint}</p>
|
||||
<p className="fdz__browse">
|
||||
<Trans>
|
||||
or <span className="fdz__browse-link">browse files</span>
|
||||
</Trans>
|
||||
</p>
|
||||
{showLimit && (
|
||||
<p className="fdz__limit">
|
||||
<Trans>Max 50 MB</Trans>
|
||||
</p>
|
||||
{showLimit && (
|
||||
<p className="fdz__limit">
|
||||
<Trans>Max 50 MB</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface FileFieldProps<T extends FieldValues> {
|
||||
showLimit?: boolean;
|
||||
disabled?: boolean;
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Side effect run after the field value changes (e.g. derive a title). */
|
||||
onValueChange?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
/** `Controller`-wrapped {@link FileDropZone} bound to react-hook-form. */
|
||||
@@ -25,12 +27,14 @@ export function FileField<T extends FieldValues>({
|
||||
showLimit,
|
||||
disabled,
|
||||
rules,
|
||||
onValueChange,
|
||||
}: FileFieldProps<T>) {
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
{label && <span className="form-label">{label}</span>}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
@@ -38,8 +42,10 @@ export function FileField<T extends FieldValues>({
|
||||
render={({ field }) => (
|
||||
<FileDropZone
|
||||
file={(field.value as File | null) ?? null}
|
||||
onChange={field.onChange}
|
||||
label={label}
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
onValueChange?.(file);
|
||||
}}
|
||||
hint={hint}
|
||||
showLimit={showLimit}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
|
||||
|
||||
import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
|
||||
import {
|
||||
type AuthResponse,
|
||||
deserializeAuthResponse,
|
||||
deserializePublicUser,
|
||||
} from "../model.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
function isTokenExpired(token: string): boolean {
|
||||
@@ -60,6 +64,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("authResponse");
|
||||
setAuthResponse(null);
|
||||
return;
|
||||
}
|
||||
// The stored user is a snapshot from login time; the role (and other
|
||||
// profile fields) can change server-side — e.g. an admin promoting this
|
||||
// user. Sync from the authoritative /me response so the UI reflects role
|
||||
// changes without forcing a logout/login cycle.
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json?.success && json.data) {
|
||||
const freshUser = deserializePublicUser(json.data);
|
||||
setAuthResponse((prev) => {
|
||||
if (!prev || prev.user.role === freshUser.role) return prev;
|
||||
const updated = { ...prev, user: freshUser };
|
||||
localStorage.setItem("authResponse", JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network error — don't log out an offline user; only an explicit 401 clears the session.
|
||||
|
||||
@@ -60,7 +60,7 @@ msgstr "← Back"
|
||||
|
||||
#: src/pages/Dump.tsx:264
|
||||
#: src/pages/Dump.tsx:494
|
||||
#: src/pages/DumpEdit.tsx:179
|
||||
#: src/pages/DumpEdit.tsx:181
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Back to all dumps"
|
||||
|
||||
@@ -154,7 +154,7 @@ msgid "Add email…"
|
||||
msgstr "Add email…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:305
|
||||
#: src/components/DumpCreateModal.tsx:301
|
||||
msgid "Add to playlist"
|
||||
msgstr "Add to playlist"
|
||||
|
||||
@@ -203,7 +203,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:376
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/DumpEdit.tsx:460
|
||||
#: src/pages/PlaylistDetail.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:1674
|
||||
#: src/pages/UserPublicProfile.tsx:1744
|
||||
@@ -291,7 +291,7 @@ msgstr "Could not change password"
|
||||
msgid "Could not load."
|
||||
msgstr "Could not load."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:359
|
||||
#: src/pages/DumpEdit.tsx:361
|
||||
msgid "Could not save"
|
||||
msgstr "Could not save"
|
||||
|
||||
@@ -346,8 +346,8 @@ msgstr "Delete category"
|
||||
msgid "Delete category \"{0}\"? This cannot be undone."
|
||||
msgstr "Delete category \"{0}\"? This cannot be undone."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:253
|
||||
#: src/pages/DumpEdit.tsx:459
|
||||
#: src/pages/DumpEdit.tsx:255
|
||||
#: src/pages/DumpEdit.tsx:456
|
||||
msgid "Delete dump"
|
||||
msgstr "Delete dump"
|
||||
|
||||
@@ -361,7 +361,7 @@ msgstr "Delete playlist"
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Delete this comment?"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:252
|
||||
#: src/pages/DumpEdit.tsx:254
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Delete this dump? This cannot be undone."
|
||||
|
||||
@@ -383,15 +383,15 @@ msgstr "deleted message"
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (optional)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:498
|
||||
#: src/components/DumpCreateModal.tsx:488
|
||||
msgid "Done"
|
||||
msgstr "Done"
|
||||
|
||||
#: src/components/FileDropZone.tsx:32
|
||||
#: src/components/FileDropZone.tsx:29
|
||||
msgid "Drop a file here"
|
||||
msgstr "Drop a file here"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:429
|
||||
#: src/pages/DumpEdit.tsx:428
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Drop a replacement here"
|
||||
|
||||
@@ -399,11 +399,11 @@ msgstr "Drop a replacement here"
|
||||
msgid "Dump"
|
||||
msgstr "Dump"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:468
|
||||
#: src/components/DumpCreateModal.tsx:458
|
||||
msgid "Dump it"
|
||||
msgstr "Dump it"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:479
|
||||
#: src/components/DumpCreateModal.tsx:469
|
||||
msgid "Dumped!"
|
||||
msgstr "Dumped!"
|
||||
|
||||
@@ -436,7 +436,7 @@ msgstr "Edit"
|
||||
msgid "Edit {0}"
|
||||
msgstr "Edit {0}"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:430
|
||||
#: src/components/DumpCreateModal.tsx:420
|
||||
msgid "Edit title"
|
||||
msgstr "Edit title"
|
||||
|
||||
@@ -460,12 +460,12 @@ msgstr "edited {0}"
|
||||
msgid "Edited {0}"
|
||||
msgstr "Edited {0}"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:204
|
||||
#: src/pages/DumpEdit.tsx:206
|
||||
msgid "Editing"
|
||||
msgstr "Editing"
|
||||
|
||||
#. placeholder {0}: state.dump.title
|
||||
#: src/pages/DumpEdit.tsx:49
|
||||
#: src/pages/DumpEdit.tsx:48
|
||||
msgid "Editing {0}"
|
||||
msgstr "Editing {0}"
|
||||
|
||||
@@ -506,7 +506,7 @@ msgstr "Failed to generate invite"
|
||||
msgid "Failed to load"
|
||||
msgstr "Failed to load"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:341
|
||||
#: src/components/DumpCreateModal.tsx:337
|
||||
msgid "Failed to post"
|
||||
msgstr "Failed to post"
|
||||
|
||||
@@ -544,20 +544,19 @@ msgstr "Failed to update role"
|
||||
msgid "Feeds"
|
||||
msgstr "Feeds"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:373
|
||||
#: src/components/DumpCreateModal.tsx:369
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Fetching preview…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:465
|
||||
#: src/components/DumpCreateModal.tsx:455
|
||||
msgid "Fetching…"
|
||||
msgstr "Fetching…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:335
|
||||
#: src/components/FileDropZone.tsx:31
|
||||
#: src/components/DumpCreateModal.tsx:331
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:243
|
||||
#: src/components/DumpCreateModal.tsx:239
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "File too large (max 50 MB)."
|
||||
|
||||
@@ -681,7 +680,7 @@ msgid "Load older messages"
|
||||
msgstr "Load older messages"
|
||||
|
||||
#: src/pages/Dump.tsx:240
|
||||
#: src/pages/DumpEdit.tsx:155
|
||||
#: src/pages/DumpEdit.tsx:157
|
||||
msgid "Loading dump…"
|
||||
msgstr "Loading dump…"
|
||||
|
||||
@@ -758,7 +757,7 @@ msgstr "Login failed"
|
||||
msgid "Manage"
|
||||
msgstr "Manage"
|
||||
|
||||
#: src/components/FileDropZone.tsx:145
|
||||
#: src/components/FileDropZone.tsx:140
|
||||
msgid "Max 50 MB"
|
||||
msgstr "Max 50 MB"
|
||||
|
||||
@@ -780,7 +779,7 @@ msgstr "new"
|
||||
msgid "New"
|
||||
msgstr "New"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:305
|
||||
#: src/components/DumpCreateModal.tsx:301
|
||||
#: src/components/DumpFab.tsx:65
|
||||
#: src/components/DumpFab.tsx:66
|
||||
#: src/pages/UserDumps.tsx:88
|
||||
@@ -873,7 +872,7 @@ msgstr "Notifications ({unreadNotificationCount} unread)"
|
||||
msgid "Open search"
|
||||
msgstr "Open search"
|
||||
|
||||
#: src/components/FileDropZone.tsx:139
|
||||
#: src/components/FileDropZone.tsx:134
|
||||
msgid "or <0>browse files</0>"
|
||||
msgstr "or <0>browse files</0>"
|
||||
|
||||
@@ -922,7 +921,7 @@ msgstr "Playlists"
|
||||
msgid "Playlists ({0}{1})"
|
||||
msgstr "Playlists ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:240
|
||||
#: src/components/DumpCreateModal.tsx:236
|
||||
msgid "Please select a file."
|
||||
msgstr "Please select a file."
|
||||
|
||||
@@ -963,11 +962,11 @@ msgstr "public"
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:233
|
||||
#: src/pages/DumpEdit.tsx:235
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Refresh metadata"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:232
|
||||
#: src/pages/DumpEdit.tsx:234
|
||||
msgid "Refreshing…"
|
||||
msgstr "Refreshing…"
|
||||
|
||||
@@ -989,7 +988,7 @@ msgstr "Registration failed"
|
||||
msgid "Related"
|
||||
msgstr "Related"
|
||||
|
||||
#: src/components/FileDropZone.tsx:115
|
||||
#: src/components/FileDropZone.tsx:110
|
||||
msgid "Remove file"
|
||||
msgstr "Remove file"
|
||||
|
||||
@@ -1005,7 +1004,7 @@ msgstr "Remove like"
|
||||
msgid "Remove vote"
|
||||
msgstr "Remove vote"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:428
|
||||
#: src/pages/DumpEdit.tsx:420
|
||||
msgid "Replace file"
|
||||
msgstr "Replace file"
|
||||
|
||||
@@ -1031,13 +1030,13 @@ msgstr "Reset failed"
|
||||
msgid "Reset password"
|
||||
msgstr "Reset password"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:379
|
||||
#: src/pages/DumpEdit.tsx:397
|
||||
#: src/pages/DumpEdit.tsx:381
|
||||
#: src/pages/DumpEdit.tsx:399
|
||||
msgid "Reset to default"
|
||||
msgstr "Reset to default"
|
||||
|
||||
#: src/pages/Dump.tsx:257
|
||||
#: src/pages/DumpEdit.tsx:172
|
||||
#: src/pages/DumpEdit.tsx:174
|
||||
msgid "Retry"
|
||||
msgstr "Retry"
|
||||
|
||||
@@ -1048,7 +1047,7 @@ msgstr "Role"
|
||||
#: src/components/ChatModal.tsx:222
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:368
|
||||
#: src/pages/DumpEdit.tsx:466
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/PlaylistDetail.tsx:927
|
||||
#: src/pages/UserPublicProfile.tsx:1666
|
||||
#: src/pages/UserPublicProfile.tsx:1736
|
||||
@@ -1149,17 +1148,17 @@ msgstr "This page does not exist."
|
||||
msgid "This reset link is missing or malformed."
|
||||
msgstr "This reset link is missing or malformed."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:363
|
||||
#: src/pages/DumpEdit.tsx:365
|
||||
msgid "Thumbnail"
|
||||
msgstr "Thumbnail"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:399
|
||||
#: src/components/DumpCreateModal.tsx:389
|
||||
#: src/components/PlaylistCreateForm.tsx:70
|
||||
#: src/pages/DumpEdit.tsx:387
|
||||
#: src/pages/DumpEdit.tsx:389
|
||||
msgid "Title"
|
||||
msgstr "Title"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:241
|
||||
#: src/components/DumpCreateModal.tsx:237
|
||||
msgid "Title is required."
|
||||
msgstr "Title is required."
|
||||
|
||||
@@ -1188,7 +1187,7 @@ msgstr "Unfollow playlist"
|
||||
msgid "Upload failed"
|
||||
msgstr "Upload failed"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:466
|
||||
#: src/components/DumpCreateModal.tsx:456
|
||||
msgid "Uploading…"
|
||||
msgstr "Uploading…"
|
||||
|
||||
@@ -1206,12 +1205,12 @@ msgstr "Upvoted"
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Upvoted ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:348
|
||||
#: src/pages/DumpEdit.tsx:410
|
||||
#: src/components/DumpCreateModal.tsx:344
|
||||
#: src/pages/DumpEdit.tsx:412
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:227
|
||||
#: src/components/DumpCreateModal.tsx:223
|
||||
msgid "URL is required."
|
||||
msgstr "URL is required."
|
||||
|
||||
@@ -1248,12 +1247,12 @@ msgstr "Users"
|
||||
msgid "View all →"
|
||||
msgstr "View all →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:481
|
||||
#: src/components/DumpCreateModal.tsx:471
|
||||
msgid "View dump →"
|
||||
msgstr "View dump →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:444
|
||||
#: src/pages/DumpEdit.tsx:440
|
||||
#: src/components/DumpCreateModal.tsx:434
|
||||
#: src/pages/DumpEdit.tsx:437
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "What makes it worth it?"
|
||||
|
||||
@@ -1262,8 +1261,8 @@ msgstr "What makes it worth it?"
|
||||
msgid "Who am I?"
|
||||
msgstr "Who am I?"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:443
|
||||
#: src/pages/DumpEdit.tsx:439
|
||||
#: src/components/DumpCreateModal.tsx:433
|
||||
#: src/pages/DumpEdit.tsx:436
|
||||
msgid "Why?"
|
||||
msgstr "Why?"
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ msgstr "← Retour"
|
||||
|
||||
#: src/pages/Dump.tsx:264
|
||||
#: src/pages/Dump.tsx:494
|
||||
#: src/pages/DumpEdit.tsx:179
|
||||
#: src/pages/DumpEdit.tsx:181
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Retour à toutes les recos"
|
||||
|
||||
@@ -154,7 +154,7 @@ msgid "Add email…"
|
||||
msgstr "Ajouter un e-mail…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:305
|
||||
#: src/components/DumpCreateModal.tsx:301
|
||||
msgid "Add to playlist"
|
||||
msgstr "Ajouter à la collection"
|
||||
|
||||
@@ -203,7 +203,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:376
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/DumpEdit.tsx:460
|
||||
#: src/pages/PlaylistDetail.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:1674
|
||||
#: src/pages/UserPublicProfile.tsx:1744
|
||||
@@ -291,7 +291,7 @@ msgstr "Impossible de changer le mot de passe"
|
||||
msgid "Could not load."
|
||||
msgstr "Impossible de charger."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:359
|
||||
#: src/pages/DumpEdit.tsx:361
|
||||
msgid "Could not save"
|
||||
msgstr "Sauvegarde impossible"
|
||||
|
||||
@@ -346,8 +346,8 @@ msgstr "Supprimer la catégorie"
|
||||
msgid "Delete category \"{0}\"? This cannot be undone."
|
||||
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:253
|
||||
#: src/pages/DumpEdit.tsx:459
|
||||
#: src/pages/DumpEdit.tsx:255
|
||||
#: src/pages/DumpEdit.tsx:456
|
||||
msgid "Delete dump"
|
||||
msgstr "Supprimer la reco"
|
||||
|
||||
@@ -361,7 +361,7 @@ msgstr "Supprimer la collection"
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Supprimer ce commentaire ?"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:252
|
||||
#: src/pages/DumpEdit.tsx:254
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||
|
||||
@@ -383,15 +383,15 @@ msgstr "message supprimé"
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (facultatif)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:498
|
||||
#: src/components/DumpCreateModal.tsx:488
|
||||
msgid "Done"
|
||||
msgstr "Terminé"
|
||||
|
||||
#: src/components/FileDropZone.tsx:32
|
||||
#: src/components/FileDropZone.tsx:29
|
||||
msgid "Drop a file here"
|
||||
msgstr "Déposez un fichier ici"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:429
|
||||
#: src/pages/DumpEdit.tsx:428
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Déposez un fichier de remplacement ici"
|
||||
|
||||
@@ -399,11 +399,11 @@ msgstr "Déposez un fichier de remplacement ici"
|
||||
msgid "Dump"
|
||||
msgstr "Reco"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:468
|
||||
#: src/components/DumpCreateModal.tsx:458
|
||||
msgid "Dump it"
|
||||
msgstr "Recommander"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:479
|
||||
#: src/components/DumpCreateModal.tsx:469
|
||||
msgid "Dumped!"
|
||||
msgstr "Recommandé !"
|
||||
|
||||
@@ -436,7 +436,7 @@ msgstr "Modifier"
|
||||
msgid "Edit {0}"
|
||||
msgstr "Modifier {0}"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:430
|
||||
#: src/components/DumpCreateModal.tsx:420
|
||||
msgid "Edit title"
|
||||
msgstr "Modifier le titre"
|
||||
|
||||
@@ -460,12 +460,12 @@ msgstr "modifié {0}"
|
||||
msgid "Edited {0}"
|
||||
msgstr "Modifié le {0}"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:204
|
||||
#: src/pages/DumpEdit.tsx:206
|
||||
msgid "Editing"
|
||||
msgstr "Modification"
|
||||
|
||||
#. placeholder {0}: state.dump.title
|
||||
#: src/pages/DumpEdit.tsx:49
|
||||
#: src/pages/DumpEdit.tsx:48
|
||||
msgid "Editing {0}"
|
||||
msgstr "Édition de {0}"
|
||||
|
||||
@@ -506,7 +506,7 @@ msgstr "Impossible de générer une invitation"
|
||||
msgid "Failed to load"
|
||||
msgstr "Chargement échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:341
|
||||
#: src/components/DumpCreateModal.tsx:337
|
||||
msgid "Failed to post"
|
||||
msgstr "Publication échouée"
|
||||
|
||||
@@ -544,20 +544,19 @@ msgstr "Erreur lors de la mise à jour du rôle"
|
||||
msgid "Feeds"
|
||||
msgstr "Flux"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:373
|
||||
#: src/components/DumpCreateModal.tsx:369
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Récupération de l'aperçu…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:465
|
||||
#: src/components/DumpCreateModal.tsx:455
|
||||
msgid "Fetching…"
|
||||
msgstr "Récupération…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:335
|
||||
#: src/components/FileDropZone.tsx:31
|
||||
#: src/components/DumpCreateModal.tsx:331
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:243
|
||||
#: src/components/DumpCreateModal.tsx:239
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||
|
||||
@@ -681,7 +680,7 @@ msgid "Load older messages"
|
||||
msgstr "Charger les messages plus anciens"
|
||||
|
||||
#: src/pages/Dump.tsx:240
|
||||
#: src/pages/DumpEdit.tsx:155
|
||||
#: src/pages/DumpEdit.tsx:157
|
||||
msgid "Loading dump…"
|
||||
msgstr "Chargement de la reco…"
|
||||
|
||||
@@ -758,7 +757,7 @@ msgstr "Connexion échouée"
|
||||
msgid "Manage"
|
||||
msgstr "Administration"
|
||||
|
||||
#: src/components/FileDropZone.tsx:145
|
||||
#: src/components/FileDropZone.tsx:140
|
||||
msgid "Max 50 MB"
|
||||
msgstr "Max 50 Mo"
|
||||
|
||||
@@ -780,7 +779,7 @@ msgstr "nouveau"
|
||||
msgid "New"
|
||||
msgstr "Nouveau"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:305
|
||||
#: src/components/DumpCreateModal.tsx:301
|
||||
#: src/components/DumpFab.tsx:65
|
||||
#: src/components/DumpFab.tsx:66
|
||||
#: src/pages/UserDumps.tsx:88
|
||||
@@ -873,7 +872,7 @@ msgstr "Notifications ({unreadNotificationCount} non lues)"
|
||||
msgid "Open search"
|
||||
msgstr "Ouvrir la recherche"
|
||||
|
||||
#: src/components/FileDropZone.tsx:139
|
||||
#: src/components/FileDropZone.tsx:134
|
||||
msgid "or <0>browse files</0>"
|
||||
msgstr "ou <0>parcourir les fichiers</0>"
|
||||
|
||||
@@ -922,7 +921,7 @@ msgstr "Collections"
|
||||
msgid "Playlists ({0}{1})"
|
||||
msgstr "Collections ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:240
|
||||
#: src/components/DumpCreateModal.tsx:236
|
||||
msgid "Please select a file."
|
||||
msgstr "Veuillez sélectionner un fichier."
|
||||
|
||||
@@ -963,11 +962,11 @@ msgstr "public"
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:233
|
||||
#: src/pages/DumpEdit.tsx:235
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Actualiser les métadonnées"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:232
|
||||
#: src/pages/DumpEdit.tsx:234
|
||||
msgid "Refreshing…"
|
||||
msgstr "Actualisation…"
|
||||
|
||||
@@ -989,7 +988,7 @@ msgstr "Inscription échouée"
|
||||
msgid "Related"
|
||||
msgstr "Connexe"
|
||||
|
||||
#: src/components/FileDropZone.tsx:115
|
||||
#: src/components/FileDropZone.tsx:110
|
||||
msgid "Remove file"
|
||||
msgstr "Supprimer le fichier"
|
||||
|
||||
@@ -1005,7 +1004,7 @@ msgstr "Retirer le j'aime"
|
||||
msgid "Remove vote"
|
||||
msgstr "Retirer le vote"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:428
|
||||
#: src/pages/DumpEdit.tsx:420
|
||||
msgid "Replace file"
|
||||
msgstr "Remplacer le fichier"
|
||||
|
||||
@@ -1031,13 +1030,13 @@ msgstr "Échec de la réinitialisation"
|
||||
msgid "Reset password"
|
||||
msgstr "Réinitialiser le mot de passe"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:379
|
||||
#: src/pages/DumpEdit.tsx:397
|
||||
#: src/pages/DumpEdit.tsx:381
|
||||
#: src/pages/DumpEdit.tsx:399
|
||||
msgid "Reset to default"
|
||||
msgstr "Réinitialiser par défaut"
|
||||
|
||||
#: src/pages/Dump.tsx:257
|
||||
#: src/pages/DumpEdit.tsx:172
|
||||
#: src/pages/DumpEdit.tsx:174
|
||||
msgid "Retry"
|
||||
msgstr "Réessayer"
|
||||
|
||||
@@ -1048,7 +1047,7 @@ msgstr "Rôle"
|
||||
#: src/components/ChatModal.tsx:222
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:368
|
||||
#: src/pages/DumpEdit.tsx:466
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/PlaylistDetail.tsx:927
|
||||
#: src/pages/UserPublicProfile.tsx:1666
|
||||
#: src/pages/UserPublicProfile.tsx:1736
|
||||
@@ -1149,17 +1148,17 @@ msgstr "Rien à voir, circulez."
|
||||
msgid "This reset link is missing or malformed."
|
||||
msgstr "Ce lien de réinitialisation est absent ou malformé."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:363
|
||||
#: src/pages/DumpEdit.tsx:365
|
||||
msgid "Thumbnail"
|
||||
msgstr "Miniature"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:399
|
||||
#: src/components/DumpCreateModal.tsx:389
|
||||
#: src/components/PlaylistCreateForm.tsx:70
|
||||
#: src/pages/DumpEdit.tsx:387
|
||||
#: src/pages/DumpEdit.tsx:389
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:241
|
||||
#: src/components/DumpCreateModal.tsx:237
|
||||
msgid "Title is required."
|
||||
msgstr "Un titre est requis."
|
||||
|
||||
@@ -1188,7 +1187,7 @@ msgstr "Ne plus suivre la collection"
|
||||
msgid "Upload failed"
|
||||
msgstr "Envoi échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:466
|
||||
#: src/components/DumpCreateModal.tsx:456
|
||||
msgid "Uploading…"
|
||||
msgstr "Envoi…"
|
||||
|
||||
@@ -1206,12 +1205,12 @@ msgstr "Voté"
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Votés ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:348
|
||||
#: src/pages/DumpEdit.tsx:410
|
||||
#: src/components/DumpCreateModal.tsx:344
|
||||
#: src/pages/DumpEdit.tsx:412
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:227
|
||||
#: src/components/DumpCreateModal.tsx:223
|
||||
msgid "URL is required."
|
||||
msgstr "L'URL est obligatoire."
|
||||
|
||||
@@ -1248,12 +1247,12 @@ msgstr "Utilisateurs"
|
||||
msgid "View all →"
|
||||
msgstr "Tout voir →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:481
|
||||
#: src/components/DumpCreateModal.tsx:471
|
||||
msgid "View dump →"
|
||||
msgstr "Voir la reco →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:444
|
||||
#: src/pages/DumpEdit.tsx:440
|
||||
#: src/components/DumpCreateModal.tsx:434
|
||||
#: src/pages/DumpEdit.tsx:437
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "Pourquoi on en voudrait ?"
|
||||
|
||||
@@ -1262,8 +1261,8 @@ msgstr "Pourquoi on en voudrait ?"
|
||||
msgid "Who am I?"
|
||||
msgstr "Qui suis-je ?"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:443
|
||||
#: src/pages/DumpEdit.tsx:439
|
||||
#: src/components/DumpCreateModal.tsx:433
|
||||
#: src/pages/DumpEdit.tsx:436
|
||||
msgid "Why?"
|
||||
msgstr "Pourquoi ?"
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { FileDropZone } from "../components/FileDropZone.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import { CategorySelect } from "../components/CategorySelect.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FileField,
|
||||
FormError,
|
||||
FormProvider,
|
||||
RichTextField,
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
VisibilityToggle,
|
||||
} from "../components/form/index.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
type DumpEditState =
|
||||
| { status: "loading" }
|
||||
@@ -69,7 +68,10 @@ export function DumpEdit() {
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
|
||||
if (apiResponse.success) {
|
||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||
setState({
|
||||
status: "loaded",
|
||||
dump: deserializeDump(apiResponse.data),
|
||||
});
|
||||
} else {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
@@ -414,22 +416,17 @@ function DumpEditForm({
|
||||
)
|
||||
: (
|
||||
<div className="form-field">
|
||||
<span className="form-label">
|
||||
<Trans>Replace file</Trans>
|
||||
</span>
|
||||
<p className="dump-file-notice">
|
||||
<strong>{dump.fileName}</strong>
|
||||
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
||||
</p>
|
||||
<Controller
|
||||
<FileField<EditValues>
|
||||
name="newFile"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FileDropZone
|
||||
file={field.value}
|
||||
onChange={field.onChange}
|
||||
label={t`Replace file`}
|
||||
hint={t`Drop a replacement here`}
|
||||
showLimit={false}
|
||||
/>
|
||||
)}
|
||||
hint={t`Drop a replacement here`}
|
||||
showLimit={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -569,6 +569,7 @@
|
||||
[data-style="nyt"] .notif-icon,
|
||||
[data-style="nyt"] .notifications-empty-icon,
|
||||
[data-style="nyt"] .notifications-title-bell,
|
||||
[data-style="nyt"] .chat-button-icon,
|
||||
[data-style="nyt"] .dump-card-preview-icon,
|
||||
[data-style="nyt"] .playlist-card-icon,
|
||||
[data-style="nyt"] .rich-content-compact-icon,
|
||||
|
||||
Reference in New Issue
Block a user