diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index bf82192..e809e85 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -46,13 +46,17 @@ export async function authMiddleware( 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(); } diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 9939267..a4b4b13 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -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 diff --git a/api/services/chat-service.ts b/api/services/chat-service.ts index 317f5e1..5de9424 100644 --- a/api/services/chat-service.ts +++ b/api/services/chat-service.ts @@ -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); } diff --git a/src/App.css b/src/App.css index 97950e8..77dd5af 100644 --- a/src/App.css +++ b/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; diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 426593a..3f104ec 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -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( ) : ( <> - name="file" - control={control} - render={({ field }) => ( - - )} + onValueChange={selectFile} + disabled={submitting} /> {file && } {file && ( diff --git a/src/components/FileDropZone.tsx b/src/components/FileDropZone.tsx index 73f11b3..4314a3e 100644 --- a/src/components/FileDropZone.tsx +++ b/src/components/FileDropZone.tsx @@ -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(null); const [dragging, setDragging] = useState(false); @@ -72,82 +69,79 @@ export function FileDropZone({ ); return ( -
- {resolvedLabel && {resolvedLabel}} -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleClick(); - } - }} - > - onChange(e.target.files?.[0] ?? null)} - disabled={disabled} - style={{ display: "none" }} - /> +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(); + } + }} + > + onChange(e.target.files?.[0] ?? null)} + disabled={disabled} + style={{ display: "none" }} + /> - {file - ? ( -
- {fileIcon(file.type)} -
- {file.name} - {formatBytes(file.size)} -
- {!disabled && ( - - )} + {file + ? ( +
+ {fileIcon(file.type)} +
+ {file.name} + {formatBytes(file.size)}
- ) - : ( -
- - - - - -

{resolvedHint}

-

- - or browse files - + ✕ + + )} +

+ ) + : ( +
+ + + + + +

{resolvedHint}

+

+ + or browse files + +

+ {showLimit && ( +

+ Max 50 MB

- {showLimit && ( -

- Max 50 MB -

- )} -
- )} -
+ )} +
+ )}
); } diff --git a/src/components/form/FileField.tsx b/src/components/form/FileField.tsx index 7d2b3f1..2a40782 100644 --- a/src/components/form/FileField.tsx +++ b/src/components/form/FileField.tsx @@ -15,6 +15,8 @@ interface FileFieldProps { showLimit?: boolean; disabled?: boolean; rules?: RegisterOptions>; + /** 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({ showLimit, disabled, rules, + onValueChange, }: FileFieldProps) { const { control, formState: { errors } } = useFormContext(); const error = errors[name]; return (
+ {label && {label}} ({ render={({ field }) => ( { + field.onChange(file); + onValueChange?.(file); + }} hint={hint} showLimit={showLimit} disabled={disabled} diff --git a/src/contexts/AuthProvider.tsx b/src/contexts/AuthProvider.tsx index 90a0311..0e6aa0f 100644 --- a/src/contexts/AuthProvider.tsx +++ b/src/contexts/AuthProvider.tsx @@ -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. diff --git a/src/locales/en.po b/src/locales/en.po index 906a1e8..266698e 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -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" msgstr "or <0>browse files" @@ -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?" diff --git a/src/locales/fr.po b/src/locales/fr.po index 0af0e55..cb648d1 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -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" msgstr "ou <0>parcourir les fichiers" @@ -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 ?" diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 46c7af0..954ab45 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -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(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({ ) : (
+ + Replace file +

{dump.fileName} {dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}

- name="newFile" - control={form.control} - render={({ field }) => ( - - )} + hint={t`Drop a replacement here`} + showLimit={false} />
)} diff --git a/src/themes/nyt.css b/src/themes/nyt.css index 8b341a4..6ce9f85 100644 --- a/src/themes/nyt.css +++ b/src/themes/nyt.css @@ -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,