v3: added localization, use global player for uploaded audio/video files
This commit is contained in:
@@ -19,7 +19,8 @@
|
|||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"justjavac.vscode-deno-extensionpack"
|
"justjavac.vscode-deno-extensionpack",
|
||||||
|
"mrorz.language-gettext"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
"dev": "deno run --env-file -A npm:vite & deno run -A server:start",
|
"dev": "deno run --env-file -A npm:vite & deno run -A server:start",
|
||||||
"build": "deno run --env-file -A npm:vite build",
|
"build": "deno run --env-file -A npm:vite build",
|
||||||
"server:start": "deno run --env-file -A --watch api/main.ts",
|
"server:start": "deno run --env-file -A --watch api/main.ts",
|
||||||
"serve": "deno run 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:compile": "lingui compile"
|
||||||
},
|
},
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'src/locales']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
@@ -19,5 +19,14 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
14
lingui.config.cjs
Normal file
14
lingui.config.cjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const { formatter } = require("@lingui/format-po");
|
||||||
|
|
||||||
|
/** @type {import("@lingui/conf").LinguiConfig} */
|
||||||
|
module.exports = {
|
||||||
|
locales: ["en", "fr"],
|
||||||
|
sourceLocale: "en",
|
||||||
|
catalogs: [
|
||||||
|
{
|
||||||
|
path: "src/locales/{locale}",
|
||||||
|
include: ["src"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: formatter(),
|
||||||
|
};
|
||||||
@@ -10,9 +10,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deno/vite-plugin": "^1.0.6",
|
"@lingui/cli": "6.0.0-next.2",
|
||||||
|
"@lingui/core": "6.0.0-next.2",
|
||||||
|
"@lingui/format-po": "6.0.0-next.2",
|
||||||
|
"@lingui/react": "6.0.0-next.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@lingui/swc-plugin": "6.0.0-next.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||||
"frimousse": "^0.3.0",
|
"frimousse": "^0.3.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -21,6 +25,7 @@
|
|||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lingui/vite-plugin": "6.0.0-next.2",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
5
scripts/lingui-extract.ts
Normal file
5
scripts/lingui-extract.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import extract from "../node_modules/@lingui/cli/dist/lingui-extract.js";
|
||||||
|
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
|
||||||
|
|
||||||
|
const config = getConfig({ cwd: Deno.cwd() });
|
||||||
|
await extract(config, { verbose: false, watch: false, files: [], workersOptions: { poolSize: 0 } });
|
||||||
142
src/App.css
142
src/App.css
@@ -511,6 +511,85 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-preview-play-btn {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-video-thumb {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 480px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-play-btn .rich-content-play-overlay {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-file-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface) 92%);
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-file-preview--active .waveform-bar {
|
||||||
|
fill: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-file-preview--active .audio-player-btn {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-audio-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-audio-btn:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-audio-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-audio-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-audio-play {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Video player ── */
|
/* ── Video player ── */
|
||||||
.video-player {
|
.video-player {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -639,6 +718,42 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Waveform ── */
|
||||||
|
.waveform-svg {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 48px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-bar {
|
||||||
|
fill: color-mix(in srgb, var(--color-accent) 25%, var(--color-border) 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-bar--played {
|
||||||
|
fill: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-skeleton {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-border) 88%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-skeleton-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: var(--color-accent);
|
||||||
|
opacity: 0.45;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
.audio-player-track--volume {
|
.audio-player-track--volume {
|
||||||
flex: 1 1 100px;
|
flex: 1 1 100px;
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
@@ -824,7 +939,8 @@
|
|||||||
.global-player--reduced .global-player-body {
|
.global-player--reduced .global-player-body {
|
||||||
grid-template-rows: 0fr;
|
grid-template-rows: 0fr;
|
||||||
}
|
}
|
||||||
.global-player-iframe-wrap {
|
.global-player-iframe-wrap,
|
||||||
|
.global-player-media-wrap {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: 0 0 8px 8px;
|
border-radius: 0 0 8px 8px;
|
||||||
@@ -1567,10 +1683,10 @@ body.has-player .fab-new {
|
|||||||
margin: 1rem auto 0 auto;
|
margin: 1rem auto 0 auto;
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent);
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--color-danger-bg) 92%, white 8%);
|
background: var(--color-danger-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-on-accent);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -2256,6 +2372,22 @@ body.has-player .fab-new {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fill the 48×48 preview box and center content for media buttons */
|
||||||
|
.dump-card-preview .rich-content-thumbnail-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-card-preview .rich-content-compact-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Shared card body ── */
|
/* ── Shared card body ── */
|
||||||
.dump-card-body,
|
.dump-card-body,
|
||||||
.playlist-card-body {
|
.playlist-card-body {
|
||||||
@@ -2494,6 +2626,8 @@ body.has-player .fab-new {
|
|||||||
.modal-body {
|
.modal-body {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
14
src/App.tsx
14
src/App.tsx
@@ -19,17 +19,12 @@ import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
|||||||
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
||||||
import { WSProvider } from "./contexts/WSProvider.tsx";
|
import { WSProvider } from "./contexts/WSProvider.tsx";
|
||||||
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
||||||
import { useAuth } from "./hooks/useAuth.ts";
|
|
||||||
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||||
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { token, user, logout } = useAuth();
|
|
||||||
return (
|
return (
|
||||||
<WSProvider token={token} userId={user?.id ?? null} onForceLogout={logout}>
|
|
||||||
<FollowProvider>
|
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
||||||
@@ -75,9 +70,6 @@ function AppRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
|
||||||
</FollowProvider>
|
|
||||||
</WSProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +77,13 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<PlayerProvider>
|
<PlayerProvider>
|
||||||
|
<WSProvider>
|
||||||
|
<FollowProvider>
|
||||||
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
</FollowProvider>
|
||||||
|
</WSProvider>
|
||||||
<GlobalPlayer />
|
<GlobalPlayer />
|
||||||
</PlayerProvider>
|
</PlayerProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,16 +0,0 @@
|
|||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
aria-hidden="true"
|
|
||||||
role="img"
|
|
||||||
class="iconify iconify--logos"
|
|
||||||
width="35.93"
|
|
||||||
height="32"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
viewBox="0 0 256 228"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="#00D8FF"
|
|
||||||
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,366 +0,0 @@
|
|||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="77"
|
|
||||||
height="47"
|
|
||||||
fill="none"
|
|
||||||
aria-labelledby="vite-logo-title"
|
|
||||||
viewBox="0 0 77 47"
|
|
||||||
>
|
|
||||||
<title id="vite-logo-title">Vite</title><style>
|
|
||||||
.parenthesis {
|
|
||||||
fill: #000;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.parenthesis {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
fill="#9135ff"
|
|
||||||
d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"
|
|
||||||
/><mask
|
|
||||||
id="a"
|
|
||||||
width="48"
|
|
||||||
height="47"
|
|
||||||
x="14"
|
|
||||||
y="0"
|
|
||||||
maskUnits="userSpaceOnUse"
|
|
||||||
style="mask-type: alpha"
|
|
||||||
><path
|
|
||||||
fill="#000"
|
|
||||||
d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"
|
|
||||||
/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse
|
|
||||||
cx="5.508"
|
|
||||||
cy="14.704"
|
|
||||||
fill="#eee6ff"
|
|
||||||
rx="5.508"
|
|
||||||
ry="14.704"
|
|
||||||
transform="rotate(269.814 20.96 11.29)scale(-1 1)"
|
|
||||||
/></g><g filter="url(#c)"><ellipse
|
|
||||||
cx="10.399"
|
|
||||||
cy="29.851"
|
|
||||||
fill="#eee6ff"
|
|
||||||
rx="10.399"
|
|
||||||
ry="29.851"
|
|
||||||
transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"
|
|
||||||
/></g><g filter="url(#d)"><ellipse
|
|
||||||
cx="5.508"
|
|
||||||
cy="30.487"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="5.508"
|
|
||||||
ry="30.487"
|
|
||||||
transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"
|
|
||||||
/></g><g filter="url(#e)"><ellipse
|
|
||||||
cx="5.508"
|
|
||||||
cy="30.599"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="5.508"
|
|
||||||
ry="30.599"
|
|
||||||
transform="rotate(89.814 -25.928 4.177)scale(1 -1)"
|
|
||||||
/></g><g filter="url(#f)"><ellipse
|
|
||||||
cx="5.508"
|
|
||||||
cy="30.599"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="5.508"
|
|
||||||
ry="30.599"
|
|
||||||
transform="rotate(89.814 -25.738 5.52)scale(1 -1)"
|
|
||||||
/></g><g filter="url(#g)"><ellipse
|
|
||||||
cx="14.072"
|
|
||||||
cy="22.078"
|
|
||||||
fill="#eee6ff"
|
|
||||||
rx="14.072"
|
|
||||||
ry="22.078"
|
|
||||||
transform="rotate(93.35 31.245 55.578)scale(-1 1)"
|
|
||||||
/></g><g filter="url(#h)"><ellipse
|
|
||||||
cx="3.47"
|
|
||||||
cy="21.501"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="3.47"
|
|
||||||
ry="21.501"
|
|
||||||
transform="rotate(89.009 35.419 55.202)scale(-1 1)"
|
|
||||||
/></g><g filter="url(#i)"><ellipse
|
|
||||||
cx="3.47"
|
|
||||||
cy="21.501"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="3.47"
|
|
||||||
ry="21.501"
|
|
||||||
transform="rotate(89.009 35.419 55.202)scale(-1 1)"
|
|
||||||
/></g><g filter="url(#j)"><ellipse
|
|
||||||
cx="14.592"
|
|
||||||
cy="9.743"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="4.407"
|
|
||||||
ry="29.108"
|
|
||||||
transform="rotate(39.51 14.592 9.743)"
|
|
||||||
/></g><g filter="url(#k)"><ellipse
|
|
||||||
cx="61.728"
|
|
||||||
cy="-5.321"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="4.407"
|
|
||||||
ry="29.108"
|
|
||||||
transform="rotate(37.892 61.728 -5.32)"
|
|
||||||
/></g><g filter="url(#l)"><ellipse
|
|
||||||
cx="55.618"
|
|
||||||
cy="7.104"
|
|
||||||
fill="#00c2ff"
|
|
||||||
rx="5.971"
|
|
||||||
ry="9.665"
|
|
||||||
transform="rotate(37.892 55.618 7.104)"
|
|
||||||
/></g><g filter="url(#m)"><ellipse
|
|
||||||
cx="12.326"
|
|
||||||
cy="39.103"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="4.407"
|
|
||||||
ry="29.108"
|
|
||||||
transform="rotate(37.892 12.326 39.103)"
|
|
||||||
/></g><g filter="url(#n)"><ellipse
|
|
||||||
cx="12.326"
|
|
||||||
cy="39.103"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="4.407"
|
|
||||||
ry="29.108"
|
|
||||||
transform="rotate(37.892 12.326 39.103)"
|
|
||||||
/></g><g filter="url(#o)"><ellipse
|
|
||||||
cx="49.857"
|
|
||||||
cy="30.678"
|
|
||||||
fill="#8900ff"
|
|
||||||
rx="4.407"
|
|
||||||
ry="29.108"
|
|
||||||
transform="rotate(37.892 49.857 30.678)"
|
|
||||||
/></g><g filter="url(#p)"><ellipse
|
|
||||||
cx="52.623"
|
|
||||||
cy="33.171"
|
|
||||||
fill="#00c2ff"
|
|
||||||
rx="5.971"
|
|
||||||
ry="15.297"
|
|
||||||
transform="rotate(37.892 52.623 33.17)"
|
|
||||||
/></g></g><path
|
|
||||||
d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789"
|
|
||||||
class="parenthesis"
|
|
||||||
/><defs><filter
|
|
||||||
id="b"
|
|
||||||
width="60.045"
|
|
||||||
height="41.654"
|
|
||||||
x="-5.564"
|
|
||||||
y="16.92"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="7.659"
|
|
||||||
/></filter><filter
|
|
||||||
id="c"
|
|
||||||
width="90.34"
|
|
||||||
height="51.437"
|
|
||||||
x="-40.407"
|
|
||||||
y="-6.762"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="7.659"
|
|
||||||
/></filter><filter
|
|
||||||
id="d"
|
|
||||||
width="79.355"
|
|
||||||
height="29.4"
|
|
||||||
x="-35.435"
|
|
||||||
y="2.801"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="e"
|
|
||||||
width="79.579"
|
|
||||||
height="29.4"
|
|
||||||
x="-30.84"
|
|
||||||
y="20.8"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="f"
|
|
||||||
width="79.579"
|
|
||||||
height="29.4"
|
|
||||||
x="-29.307"
|
|
||||||
y="21.949"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="g"
|
|
||||||
width="74.749"
|
|
||||||
height="58.852"
|
|
||||||
x="29.961"
|
|
||||||
y="-17.13"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="7.659"
|
|
||||||
/></filter><filter
|
|
||||||
id="h"
|
|
||||||
width="61.377"
|
|
||||||
height="25.362"
|
|
||||||
x="37.754"
|
|
||||||
y="3.055"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="i"
|
|
||||||
width="61.377"
|
|
||||||
height="25.362"
|
|
||||||
x="37.754"
|
|
||||||
y="3.055"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="j"
|
|
||||||
width="56.045"
|
|
||||||
height="63.649"
|
|
||||||
x="-13.43"
|
|
||||||
y="-22.082"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="k"
|
|
||||||
width="54.814"
|
|
||||||
height="64.646"
|
|
||||||
x="34.321"
|
|
||||||
y="-37.644"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="l"
|
|
||||||
width="33.541"
|
|
||||||
height="35.313"
|
|
||||||
x="38.847"
|
|
||||||
y="-10.552"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="m"
|
|
||||||
width="54.814"
|
|
||||||
height="64.646"
|
|
||||||
x="-15.081"
|
|
||||||
y="6.78"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="n"
|
|
||||||
width="54.814"
|
|
||||||
height="64.646"
|
|
||||||
x="-15.081"
|
|
||||||
y="6.78"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="o"
|
|
||||||
width="54.814"
|
|
||||||
height="64.646"
|
|
||||||
x="22.45"
|
|
||||||
y="-1.645"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter><filter
|
|
||||||
id="p"
|
|
||||||
width="39.409"
|
|
||||||
height="43.623"
|
|
||||||
x="32.919"
|
|
||||||
y="11.36"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/><feGaussianBlur
|
|
||||||
result="effect1_foregroundBlur_2002_17286"
|
|
||||||
stdDeviation="4.596"
|
|
||||||
/></filter></defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
|
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
|
||||||
@@ -32,7 +33,7 @@ export function AddToPlaylistModal(
|
|||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [dumpId]);
|
}, [dumpId, authFetch]);
|
||||||
|
|
||||||
const toggleMembership = async (membership: PlaylistMembership) => {
|
const toggleMembership = async (membership: PlaylistMembership) => {
|
||||||
const { playlist, hasDump } = membership;
|
const { playlist, hasDump } = membership;
|
||||||
@@ -60,7 +61,7 @@ export function AddToPlaylistModal(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Add to playlist" onClose={onClose}>
|
<Modal title={t`Add to playlist`} onClose={onClose}>
|
||||||
<PlaylistMembershipPanel
|
<PlaylistMembershipPanel
|
||||||
dumpId={dumpId}
|
dumpId={dumpId}
|
||||||
memberships={memberships}
|
memberships={memberships}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { type ReactNode, useState } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
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 { DumpCreateModal } from "./DumpCreateModal.tsx";
|
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
||||||
@@ -41,7 +43,7 @@ export function AppHeader(
|
|||||||
to={`/users/${user.username}/playlists`}
|
to={`/users/${user.username}/playlists`}
|
||||||
className="app-header-user"
|
className="app-header-user"
|
||||||
>
|
>
|
||||||
Playlists
|
<Trans>Playlists</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,16 +58,16 @@ export function AppHeader(
|
|||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
onClick={() => setCreateModalOpen(true)}
|
onClick={() => setCreateModalOpen(true)}
|
||||||
disabled={disableNew}
|
disabled={disableNew}
|
||||||
title={disableNew ? "Server unreachable" : undefined}
|
title={disableNew ? t`Server unreachable` : undefined}
|
||||||
>
|
>
|
||||||
+ New
|
<Trans>+ New</Trans>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={() => navigate("/login")}>
|
<button type="button" onClick={() => navigate("/login")}>
|
||||||
Log in
|
<Trans>Log in</Trans>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -74,7 +76,8 @@ export function AppHeader(
|
|||||||
|
|
||||||
{wsStatus === "disconnected" && wsErrorMessage && (
|
{wsStatus === "disconnected" && wsErrorMessage && (
|
||||||
<div className="app-header-status" role="alert">
|
<div className="app-header-status" role="alert">
|
||||||
<strong>Live updates unavailable.</strong> {wsErrorMessage}
|
<strong><Trans>Live updates unavailable.</Trans></strong>{" "}
|
||||||
|
{wsErrorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type {
|
import type {
|
||||||
Comment,
|
Comment,
|
||||||
@@ -103,7 +105,7 @@ function CommentNode({
|
|||||||
setReplyError(data.error.message);
|
setReplyError(data.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setReplyError("Could not reach the server. Please try again.");
|
setReplyError(t`Could not reach the server. Please try again.`);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ function CommentNode({
|
|||||||
setEditError(data.error.message);
|
setEditError(data.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setEditError("Could not reach the server. Please try again.");
|
setEditError(t`Could not reach the server. Please try again.`);
|
||||||
} finally {
|
} finally {
|
||||||
setEditSubmitting(false);
|
setEditSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -164,7 +166,9 @@ function CommentNode({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="comment-content">
|
<div className="comment-content">
|
||||||
<p className="comment-deleted-placeholder">[deleted]</p>
|
<p className="comment-deleted-placeholder">
|
||||||
|
<Trans>[deleted]</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
@@ -222,9 +226,9 @@ function CommentNode({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Link>
|
</Link>
|
||||||
{comment.updatedAt && (
|
{comment.updatedAt && (
|
||||||
<Tooltip text={`Edited ${comment.updatedAt.toLocaleString()}`}>
|
<Tooltip text={t`Edited ${comment.updatedAt.toLocaleString()}`}>
|
||||||
<span className="comment-edited">
|
<span className="comment-edited">
|
||||||
edited {relativeTime(comment.updatedAt)}
|
<Trans>edited {relativeTime(comment.updatedAt)}</Trans>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -242,7 +246,7 @@ function CommentNode({
|
|||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
{editError && (
|
{editError && (
|
||||||
<ErrorCard title="Failed to save edit" message={editError} />
|
<ErrorCard title={t`Failed to save edit`} message={editError} />
|
||||||
)}
|
)}
|
||||||
<div className="comment-form-actions">
|
<div className="comment-form-actions">
|
||||||
<button
|
<button
|
||||||
@@ -250,7 +254,7 @@ function CommentNode({
|
|||||||
className="comment-submit-btn"
|
className="comment-submit-btn"
|
||||||
disabled={editSubmitting || !editBody.trim()}
|
disabled={editSubmitting || !editBody.trim()}
|
||||||
>
|
>
|
||||||
{editSubmitting ? "Saving…" : "Save"}
|
{editSubmitting ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -261,7 +265,7 @@ function CommentNode({
|
|||||||
setEditError(null);
|
setEditError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -277,7 +281,7 @@ function CommentNode({
|
|||||||
setTimeout(() => replyEditorRef.current?.focus(), 0);
|
setTimeout(() => replyEditorRef.current?.focus(), 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reply
|
<Trans>Reply</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEdit && !editOpen && (
|
{canEdit && !editOpen && (
|
||||||
@@ -290,7 +294,7 @@ function CommentNode({
|
|||||||
setTimeout(() => editEditorRef.current?.focus(), 0);
|
setTimeout(() => editEditorRef.current?.focus(), 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canDelete && !editOpen && (
|
{canDelete && !editOpen && (
|
||||||
@@ -299,13 +303,13 @@ function CommentNode({
|
|||||||
className="comment-action-btn comment-delete-btn"
|
className="comment-action-btn comment-delete-btn"
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
>
|
>
|
||||||
Delete
|
<Trans>Delete</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
message="Delete this comment?"
|
message={t`Delete this comment?`}
|
||||||
confirmLabel="Delete"
|
confirmLabel={t`Delete`}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
handleDelete();
|
handleDelete();
|
||||||
@@ -322,12 +326,12 @@ function CommentNode({
|
|||||||
value={replyBody}
|
value={replyBody}
|
||||||
onChange={setReplyBody}
|
onChange={setReplyBody}
|
||||||
onSubmit={handleReply}
|
onSubmit={handleReply}
|
||||||
placeholder="Write a reply…"
|
placeholder={t`Write a reply…`}
|
||||||
autoResize
|
autoResize
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
{replyError && (
|
{replyError && (
|
||||||
<ErrorCard title="Failed to post reply" message={replyError} />
|
<ErrorCard title={t`Failed to post reply`} message={replyError} />
|
||||||
)}
|
)}
|
||||||
<div className="comment-form-actions">
|
<div className="comment-form-actions">
|
||||||
<button
|
<button
|
||||||
@@ -335,7 +339,7 @@ function CommentNode({
|
|||||||
className="comment-submit-btn"
|
className="comment-submit-btn"
|
||||||
disabled={submitting || !replyBody.trim()}
|
disabled={submitting || !replyBody.trim()}
|
||||||
>
|
>
|
||||||
{submitting ? "Posting…" : "Post reply"}
|
{submitting ? <Trans>Posting…</Trans> : <Trans>Post reply</Trans>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -346,7 +350,7 @@ function CommentNode({
|
|||||||
setReplyError(null);
|
setReplyError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -418,19 +422,18 @@ export function CommentThread({
|
|||||||
setTopLevelError(data.error.message);
|
setTopLevelError(data.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setTopLevelError("Could not reach the server. Please try again.");
|
setTopLevelError(t`Could not reach the server. Please try again.`);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleCount = comments.filter((c) => !c.deleted).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="comment-section">
|
<section className="comment-section">
|
||||||
<h2 className="comment-section-title">
|
<h2 className="comment-section-title">
|
||||||
{(() => {
|
<Plural value={visibleCount} one="# comment" other="# comments" />
|
||||||
const n = comments.filter((c) => !c.deleted).length;
|
|
||||||
return n === 1 ? "1 comment" : `${n} comments`;
|
|
||||||
})()}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
@@ -450,13 +453,13 @@ export function CommentThread({
|
|||||||
value={topLevelBody}
|
value={topLevelBody}
|
||||||
onChange={setTopLevelBody}
|
onChange={setTopLevelBody}
|
||||||
onSubmit={handleTopLevelSubmit}
|
onSubmit={handleTopLevelSubmit}
|
||||||
placeholder="Add a comment…"
|
placeholder={t`Add a comment…`}
|
||||||
autoResize
|
autoResize
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
{topLevelError && (
|
{topLevelError && (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
title="Failed to post comment"
|
title={t`Failed to post comment`}
|
||||||
message={topLevelError}
|
message={topLevelError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -466,7 +469,7 @@ export function CommentThread({
|
|||||||
className="comment-submit-btn"
|
className="comment-submit-btn"
|
||||||
disabled={submitting || !topLevelBody.trim()}
|
disabled={submitting || !topLevelBody.trim()}
|
||||||
>
|
>
|
||||||
{submitting ? "Posting…" : "Post comment"}
|
{submitting ? <Trans>Posting…</Trans> : <Trans>Post comment</Trans>}
|
||||||
</button>
|
</button>
|
||||||
{topLevelBody.trim() && (
|
{topLevelBody.trim() && (
|
||||||
<button
|
<button
|
||||||
@@ -477,7 +480,7 @@ export function CommentThread({
|
|||||||
setTopLevelError(null);
|
setTopLevelError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
interface ConfirmModalProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -9,8 +11,10 @@ interface ConfirmModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmModal(
|
export function ConfirmModal(
|
||||||
{ message, confirmLabel = "Delete", onConfirm, onCancel }: ConfirmModalProps,
|
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
|
||||||
) {
|
) {
|
||||||
|
const label = confirmLabel ?? t`Delete`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onCancel();
|
if (e.key === "Escape") onCancel();
|
||||||
@@ -24,9 +28,11 @@ export function ConfirmModal(
|
|||||||
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<p className="confirm-modal-message">{message}</p>
|
<p className="confirm-modal-message">{message}</p>
|
||||||
<div className="confirm-modal-actions">
|
<div className="confirm-modal-actions">
|
||||||
<button type="button" onClick={onCancel}>Cancel</button>
|
<button type="button" onClick={onCancel}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</button>
|
||||||
<button type="button" className="btn-danger" onClick={onConfirm}>
|
<button type="button" className="btn-danger" onClick={onConfirm}>
|
||||||
{confirmLabel}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
import { dumpUrl } from "../utils/urls.ts";
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
@@ -78,12 +79,17 @@ export function DumpCard(
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
{dump.commentCount > 0 && (
|
{dump.commentCount > 0 && (
|
||||||
<span className="dump-card-comment-count">
|
<span className="dump-card-comment-count">
|
||||||
{dump.commentCount}{" "}
|
<Plural
|
||||||
{dump.commentCount === 1 ? "comment" : "comments"}
|
value={dump.commentCount}
|
||||||
|
one="# comment"
|
||||||
|
other="# comments"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{dump.isPrivate && isOwner && (
|
{dump.isPrivate && isOwner && (
|
||||||
<span className="dump-card-private-badge">private</span>
|
<span className="dump-card-private-badge">
|
||||||
|
<Trans>private</Trans>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -26,6 +28,13 @@ import { TextEditor } from "./TextEditor.tsx";
|
|||||||
import { Modal } from "./Modal.tsx";
|
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";
|
import { MAX_FILE_SIZE } from "../config/upload.ts";
|
||||||
|
|
||||||
type Mode = "url" | "file";
|
type Mode = "url" | "file";
|
||||||
@@ -42,11 +51,16 @@ type UrlPreview =
|
|||||||
| { status: "done"; richContent: RichContent | null };
|
| { status: "done"; richContent: RichContent | null };
|
||||||
|
|
||||||
function LocalFilePreview({ file }: { file: File }) {
|
function LocalFilePreview({ file }: { file: File }) {
|
||||||
const src = useMemo(() => URL.createObjectURL(file), [file]);
|
// useRef instead of useMemo+useEffect: StrictMode double-invokes effect
|
||||||
|
// cleanups, which would revoke the blob URL before the video element can use it.
|
||||||
|
const blobRef = useRef<{ file: File; url: string } | null>(null);
|
||||||
|
if (blobRef.current?.file !== file) {
|
||||||
|
if (blobRef.current) URL.revokeObjectURL(blobRef.current.url);
|
||||||
|
blobRef.current = { file, url: URL.createObjectURL(file) };
|
||||||
|
}
|
||||||
|
const src = blobRef.current.url;
|
||||||
const mime = file.type;
|
const mime = file.type;
|
||||||
|
|
||||||
useEffect(() => () => URL.revokeObjectURL(src), [src]);
|
|
||||||
|
|
||||||
if (mime.startsWith("image/")) {
|
if (mime.startsWith("image/")) {
|
||||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||||
}
|
}
|
||||||
@@ -92,7 +106,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
|
|
||||||
let trimmed: string;
|
let trimmed: string;
|
||||||
try {
|
try {
|
||||||
const u = new URL(url.trim());
|
const u = new URL(normalizeUrl(url));
|
||||||
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
|
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
|
||||||
trimmed = u.toString();
|
trimmed = u.toString();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -137,11 +151,11 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||||
const text = e.clipboardData?.getData("text") ?? "";
|
const text = e.clipboardData?.getData("text") ?? "";
|
||||||
try {
|
try {
|
||||||
const u = new URL(text.trim());
|
const u = new URL(normalizeUrl(text));
|
||||||
if (u.protocol === "http:" || u.protocol === "https:") {
|
if (u.protocol === "http:" || u.protocol === "https:") {
|
||||||
setMode("url");
|
setMode("url");
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setUrl(text.trim());
|
setUrl(u.toString());
|
||||||
setSubmitState({ status: "idle" });
|
setSubmitState({ status: "idle" });
|
||||||
}
|
}
|
||||||
} catch { /* not a URL */ }
|
} catch { /* not a URL */ }
|
||||||
@@ -158,12 +172,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
let res: Response;
|
let res: Response;
|
||||||
|
|
||||||
if (mode === "url") {
|
if (mode === "url") {
|
||||||
if (!url.trim()) {
|
const normalizedUrl = normalizeUrl(url);
|
||||||
setSubmitState({ status: "error", error: "URL is required." });
|
if (!normalizedUrl) {
|
||||||
|
setSubmitState({ status: "error", error: t`URL is required.` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setUrl(normalizedUrl);
|
||||||
const body: CreateUrlDumpRequest = {
|
const body: CreateUrlDumpRequest = {
|
||||||
url: url.trim(),
|
url: normalizedUrl,
|
||||||
comment: comment.trim() || undefined,
|
comment: comment.trim() || undefined,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
};
|
};
|
||||||
@@ -173,13 +189,16 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setSubmitState({ status: "error", error: "Please select a file." });
|
setSubmitState({
|
||||||
|
status: "error",
|
||||||
|
error: t`Please select a file.`,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
setSubmitState({
|
setSubmitState({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: "File too large (max 50 MB).",
|
error: t`File too large (max 50 MB).`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -254,7 +273,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={phase === "create" ? "New dump" : "Add to playlist"}
|
title={phase === "create" ? t`New dump` : t`Add to playlist`}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
@@ -285,14 +304,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
}}
|
}}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
📎 File
|
📎 <Trans>File</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="dump-form">
|
<form onSubmit={handleSubmit} className="dump-form">
|
||||||
{submitState.status === "error" && (
|
{submitState.status === "error" && (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
title="Failed to post"
|
title={t`Failed to post`}
|
||||||
message={submitState.error}
|
message={submitState.error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -301,12 +320,13 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="dc-url">URL</label>
|
<label htmlFor="dc-url"><Trans>URL</Trans></label>
|
||||||
<input
|
<input
|
||||||
id="dc-url"
|
id="dc-url"
|
||||||
type="url"
|
type="url"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onBlur={(e) => setUrl(normalizeUrl(e.target.value))}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
const pastedFile = e.clipboardData.files[0];
|
const pastedFile = e.clipboardData.files[0];
|
||||||
if (pastedFile) {
|
if (pastedFile) {
|
||||||
@@ -325,7 +345,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{urlPreview.status === "loading" && (
|
{urlPreview.status === "loading" && (
|
||||||
<p className="preview-loading">Fetching preview…</p>
|
<p className="preview-loading"><Trans>Fetching preview…</Trans></p>
|
||||||
)}
|
)}
|
||||||
{urlPreview.status === "done" &&
|
{urlPreview.status === "done" &&
|
||||||
urlPreview.richContent && (
|
urlPreview.richContent && (
|
||||||
@@ -348,14 +368,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="dc-comment">
|
<label htmlFor="dc-comment">
|
||||||
Why are you dumping this?
|
<Trans>Why are you dumping this?</Trans>
|
||||||
</label>
|
</label>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
id="dc-comment"
|
id="dc-comment"
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={setComment}
|
onChange={setComment}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
placeholder="Tell the community what makes this worth their time..."
|
placeholder={t`Tell the community what makes this worth their time...`}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,7 +387,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onClick={() => setIsPrivate(false)}
|
onClick={() => setIsPrivate(false)}
|
||||||
>
|
>
|
||||||
Public
|
<Trans>Public</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -375,7 +395,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onClick={() => setIsPrivate(true)}
|
onClick={() => setIsPrivate(true)}
|
||||||
>
|
>
|
||||||
Private
|
<Trans>Private</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -386,7 +406,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
className="form-cancel"
|
className="form-cancel"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -394,8 +414,10 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting
|
||||||
? (mode === "url" ? "Fetching…" : "Uploading…")
|
? (mode === "url"
|
||||||
: "Dump it"}
|
? <Trans>Fetching…</Trans>
|
||||||
|
: <Trans>Uploading…</Trans>)
|
||||||
|
: <Trans>Dump it</Trans>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,9 +428,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
<>
|
<>
|
||||||
{createdDump && (
|
{createdDump && (
|
||||||
<p className="dump-create-success">
|
<p className="dump-create-success">
|
||||||
Dumped!{" "}
|
<Trans>Dumped!</Trans>{" "}
|
||||||
<Link to={dumpUrl(createdDump)} onClick={onClose}>
|
<Link to={dumpUrl(createdDump)} onClick={onClose}>
|
||||||
View dump →
|
<Trans>View dump →</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -429,7 +451,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Done
|
<Trans>Done</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { type FeedTab, VALID_TABS } from "../config/feedTabs.ts";
|
||||||
export type FeedTab = "hot" | "new" | "journal" | "followed";
|
|
||||||
export const VALID_TABS = new Set<string>([
|
|
||||||
"hot",
|
|
||||||
"new",
|
|
||||||
"journal",
|
|
||||||
"followed",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function FeedTabBar() {
|
export function FeedTabBar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -28,21 +22,21 @@ export function FeedTabBar() {
|
|||||||
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
|
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
|
||||||
onClick={() => setTab("hot")}
|
onClick={() => setTab("hot")}
|
||||||
>
|
>
|
||||||
Hot
|
<Trans>Hot</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
|
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
|
||||||
onClick={() => setTab("new")}
|
onClick={() => setTab("new")}
|
||||||
>
|
>
|
||||||
New
|
<Trans>New</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
|
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
|
||||||
onClick={() => setTab("journal")}
|
onClick={() => setTab("journal")}
|
||||||
>
|
>
|
||||||
Journal
|
<Trans>Journal</Trans>
|
||||||
</button>
|
</button>
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
@@ -50,7 +44,7 @@ export function FeedTabBar() {
|
|||||||
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
|
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
|
||||||
onClick={() => setTab("followed")}
|
onClick={() => setTab("followed")}
|
||||||
>
|
>
|
||||||
Followed
|
<Trans>Followed</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { formatBytes } from "../utils/format.ts";
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
|
||||||
function fileIcon(mime: string): string {
|
function fileIcon(mime: string): string {
|
||||||
@@ -22,10 +24,12 @@ export function FileDropZone({
|
|||||||
file,
|
file,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
label = "File",
|
label,
|
||||||
hint = "Drop a file here",
|
hint,
|
||||||
showLimit = true,
|
showLimit = true,
|
||||||
}: FileDropZoneProps) {
|
}: FileDropZoneProps) {
|
||||||
|
const resolvedLabel = label ?? t`File`;
|
||||||
|
const resolvedHint = hint ?? t`Drop a file here`;
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
@@ -69,7 +73,7 @@ export function FileDropZone({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fdz-wrapper">
|
<div className="fdz-wrapper">
|
||||||
{label && <span className="fdz-label">{label}</span>}
|
{resolvedLabel && <span className="fdz-label">{resolvedLabel}</span>}
|
||||||
<div
|
<div
|
||||||
className={`fdz${dragging ? " fdz--drag" : ""}${
|
className={`fdz${dragging ? " fdz--drag" : ""}${
|
||||||
disabled ? " fdz--disabled" : ""
|
disabled ? " fdz--disabled" : ""
|
||||||
@@ -108,7 +112,7 @@ export function FileDropZone({
|
|||||||
type="button"
|
type="button"
|
||||||
className="fdz__clear"
|
className="fdz__clear"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
aria-label="Remove file"
|
aria-label={t`Remove file`}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -130,11 +134,11 @@ export function FileDropZone({
|
|||||||
<polyline points="17 8 12 3 7 8" />
|
<polyline points="17 8 12 3 7 8" />
|
||||||
<line x1="12" y1="3" x2="12" y2="15" />
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="fdz__hint">{hint}</p>
|
<p className="fdz__hint">{resolvedHint}</p>
|
||||||
<p className="fdz__browse">
|
<p className="fdz__browse">
|
||||||
or <span className="fdz__browse-link">browse files</span>
|
<Trans>or <span className="fdz__browse-link">browse files</span></Trans>
|
||||||
</p>
|
</p>
|
||||||
{showLimit && <p className="fdz__limit">Max 50 MB</p>}
|
{showLimit && <p className="fdz__limit"><Trans>Max 50 MB</Trans></p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,119 @@
|
|||||||
|
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 { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
|
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||||
|
import {
|
||||||
|
BAR_GAP,
|
||||||
|
BAR_W,
|
||||||
|
extractPeaks,
|
||||||
|
NUM_BARS,
|
||||||
|
VIEWBOX_W,
|
||||||
|
WAVEFORM_H,
|
||||||
|
} from "../utils/waveform.ts";
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
dump: Dump;
|
dump: Dump;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
global?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waveform preview for the dump detail page — routes to global player,
|
||||||
|
// reflects live play state and position from PlayerContext.
|
||||||
|
function AudioFilePreview(
|
||||||
|
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
||||||
|
) {
|
||||||
|
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
|
||||||
|
useContext(PlayerContext);
|
||||||
|
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||||
|
const isActive = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||||
|
const progress = isActive && duration > 0 ? currentTime / duration : 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
extractPeaks(fileUrl, NUM_BARS)
|
||||||
|
.then((p) => { if (!cancelled) setPeaks(p); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [fileUrl]);
|
||||||
|
|
||||||
|
const handlePlayBtn = () => {
|
||||||
|
if (isActive) togglePlay();
|
||||||
|
else play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWaveformClick = (e: React.MouseEvent<Element>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
|
if (isActive) {
|
||||||
|
seekTo(ratio * duration);
|
||||||
|
} else {
|
||||||
|
// Start playing and seek once it loads — seekTo after play() is a no-op
|
||||||
|
// until MediaPlayer mounts; the fraction is best-effort on first click
|
||||||
|
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPlaying = isActive && playing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`audio-file-preview${isActive ? " audio-file-preview--active" : ""}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="audio-player-btn"
|
||||||
|
onClick={handlePlayBtn}
|
||||||
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
|
>
|
||||||
|
{isPlaying
|
||||||
|
? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||||
|
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||||
|
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||||
|
<polygon points="6,3 20,12 6,21" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{peaks
|
||||||
|
? (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="waveform-svg"
|
||||||
|
onClick={handleWaveformClick}
|
||||||
|
>
|
||||||
|
{Array.from(peaks).map((p, i) => {
|
||||||
|
const barH = Math.max(p * WAVEFORM_H, 2);
|
||||||
|
const x = i * (BAR_W + BAR_GAP);
|
||||||
|
const y = (WAVEFORM_H - barH) / 2;
|
||||||
|
const played = i / NUM_BARS <= progress;
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={BAR_W}
|
||||||
|
height={barH}
|
||||||
|
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="waveform-skeleton" onClick={handleWaveformClick}>
|
||||||
|
<div
|
||||||
|
className="waveform-skeleton-fill"
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mimeIcon(mime: string): string {
|
function mimeIcon(mime: string): string {
|
||||||
@@ -17,10 +125,13 @@ function mimeIcon(mime: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FilePreview(
|
export default function FilePreview(
|
||||||
{ dump, compact = false }: FilePreviewProps,
|
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
|
||||||
) {
|
) {
|
||||||
|
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
||||||
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
|
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
|
||||||
const mime = dump.fileMime ?? "";
|
const mime = dump.fileMime ?? "";
|
||||||
|
const isMedia = mime.startsWith("video/") || mime.startsWith("audio/");
|
||||||
|
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
if (mime.startsWith("image/")) {
|
if (mime.startsWith("image/")) {
|
||||||
@@ -35,6 +146,45 @@ export default function FilePreview(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (mime.startsWith("video/")) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={fileUrl}
|
||||||
|
preload="metadata"
|
||||||
|
className="rich-content-compact-thumbnail"
|
||||||
|
muted
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
(e.target as HTMLVideoElement).currentTime = 0.1;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="rich-content-play-overlay">▶</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mime.startsWith("audio/")) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rich-content-compact-icon rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mimeIcon(mime)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
|
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +195,37 @@ export default function FilePreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mime.startsWith("video/")) {
|
if (mime.startsWith("video/")) {
|
||||||
|
if (useGlobal) {
|
||||||
|
const videoActive = isPlaying;
|
||||||
|
const videoPlaying = videoActive && playing;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
|
||||||
|
onClick={() => videoActive ? togglePlay() : play({ kind: "file", fileUrl, mimeType: mime, title: dump.title })}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={fileUrl}
|
||||||
|
preload="metadata"
|
||||||
|
className="file-preview-video-thumb"
|
||||||
|
muted
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
(e.target as HTMLVideoElement).currentTime = 0.1;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="rich-content-play-overlay">
|
||||||
|
{videoPlaying ? "⏸" : "▶"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />;
|
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mime.startsWith("audio/")) {
|
if (mime.startsWith("audio/")) {
|
||||||
|
if (useGlobal) {
|
||||||
|
return <AudioFilePreview fileUrl={fileUrl} mime={mime} dump={dump} />;
|
||||||
|
}
|
||||||
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
|
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useFollows } from "../hooks/useFollows.ts";
|
import { useFollows } from "../hooks/useFollows.ts";
|
||||||
|
|
||||||
@@ -29,10 +31,10 @@ export function FollowUserButton(
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
|
isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
|
||||||
aria-label={isFollowing
|
aria-label={isFollowing
|
||||||
? `Unfollow ${targetUsername}`
|
? t`Unfollow ${targetUsername}`
|
||||||
: `Follow ${targetUsername}`}
|
: t`Follow ${targetUsername}`}
|
||||||
>
|
>
|
||||||
{isFollowing ? "Following" : "Follow"}
|
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,9 +59,9 @@ export function FollowPlaylistButton(
|
|||||||
isFollowing
|
isFollowing
|
||||||
? unfollowPlaylist(targetPlaylistId)
|
? unfollowPlaylist(targetPlaylistId)
|
||||||
: followPlaylist(targetPlaylistId)}
|
: followPlaylist(targetPlaylistId)}
|
||||||
aria-label={isFollowing ? "Unfollow playlist" : "Follow playlist"}
|
aria-label={isFollowing ? t`Unfollow playlist` : t`Follow playlist`}
|
||||||
>
|
>
|
||||||
{isFollowing ? "Following" : "Follow"}
|
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { useContext, useEffect, useRef, useState } from "react";
|
import { useContext, useEffect, useRef, useState } from "react";
|
||||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||||
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
|
|
||||||
|
function itemKey(item: { kind: string; embedUrl?: string; fileUrl?: string } | null) {
|
||||||
|
if (!item) return null;
|
||||||
|
return item.kind === "embed" ? item.embedUrl : item.fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
export function GlobalPlayer() {
|
export function GlobalPlayer() {
|
||||||
const { current, stop } = useContext(PlayerContext);
|
const { current, stop, seekRef, toggleRef, onPlayStateChange, onTimeUpdate } =
|
||||||
|
useContext(PlayerContext);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [reduced, setReduced] = useState(false);
|
const [reduced, setReduced] = useState(false);
|
||||||
|
const [prevKey, setPrevKey] = useState(itemKey(current));
|
||||||
|
|
||||||
|
if (prevKey !== itemKey(current)) {
|
||||||
|
setPrevKey(itemKey(current));
|
||||||
|
if (current) setReduced(false);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -33,23 +46,21 @@ export function GlobalPlayer() {
|
|||||||
};
|
};
|
||||||
}, [current]);
|
}, [current]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (current) setReduced(false);
|
|
||||||
}, [current?.embedUrl]);
|
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
|
const typeClass = current.kind === "embed"
|
||||||
|
? current.type
|
||||||
|
: current.mimeType.startsWith("video/") ? "file-video" : "file-audio";
|
||||||
|
|
||||||
|
const title = current.title ?? (current.kind === "embed" ? current.embedUrl : current.fileUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`global-player global-player--${current.type}${
|
className={`global-player global-player--${typeClass}${reduced ? " global-player--reduced" : ""}`}
|
||||||
reduced ? " global-player--reduced" : ""
|
|
||||||
}`}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<div className="global-player-header">
|
<div className="global-player-header">
|
||||||
<span className="global-player-title">
|
<span className="global-player-title">{title}</span>
|
||||||
{current.title ?? current.embedUrl}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--ghost"
|
className="btn btn--ghost"
|
||||||
@@ -62,6 +73,8 @@ export function GlobalPlayer() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="global-player-body">
|
<div className="global-player-body">
|
||||||
|
{current.kind === "embed"
|
||||||
|
? (
|
||||||
<div className="global-player-iframe-wrap">
|
<div className="global-player-iframe-wrap">
|
||||||
<iframe
|
<iframe
|
||||||
src={current.embedUrl}
|
src={current.embedUrl}
|
||||||
@@ -70,6 +83,22 @@ export function GlobalPlayer() {
|
|||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="global-player-media-wrap">
|
||||||
|
<MediaPlayer
|
||||||
|
key={current.fileUrl}
|
||||||
|
src={current.fileUrl}
|
||||||
|
kind={current.mimeType.startsWith("video/") ? "video" : "audio"}
|
||||||
|
mime={current.mimeType}
|
||||||
|
autoplay
|
||||||
|
onPlayStateChange={onPlayStateChange}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
seekRef={seekRef}
|
||||||
|
toggleRef={toggleRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
BAR_GAP,
|
||||||
|
BAR_W,
|
||||||
|
extractPeaks,
|
||||||
|
NUM_BARS,
|
||||||
|
VIEWBOX_W,
|
||||||
|
WAVEFORM_H,
|
||||||
|
} from "../utils/waveform.ts";
|
||||||
|
|
||||||
function fmt(s: number): string {
|
function fmt(s: number): string {
|
||||||
if (!isFinite(s)) return "0:00";
|
if (!isFinite(s)) return "0:00";
|
||||||
@@ -38,15 +46,91 @@ const IconFullscreen = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Waveform ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Waveform(
|
||||||
|
{ src, current, duration, onSeek }: {
|
||||||
|
src: string;
|
||||||
|
current: number;
|
||||||
|
duration: number;
|
||||||
|
onSeek: (t: number) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
extractPeaks(src, NUM_BARS)
|
||||||
|
.then((p) => { if (!cancelled) setPeaks(p); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const progress = duration > 0 ? current / duration : 0;
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<Element>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
|
onSeek(ratio * duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!peaks) {
|
||||||
|
return (
|
||||||
|
<div className="waveform-skeleton" onClick={handleClick}>
|
||||||
|
<div
|
||||||
|
className="waveform-skeleton-fill"
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="waveform-svg"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{Array.from(peaks).map((p, i) => {
|
||||||
|
const barH = Math.max(p * WAVEFORM_H, 2);
|
||||||
|
const x = i * (BAR_W + BAR_GAP);
|
||||||
|
const y = (WAVEFORM_H - barH) / 2;
|
||||||
|
const played = i / NUM_BARS <= progress;
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={BAR_W}
|
||||||
|
height={barH}
|
||||||
|
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MediaPlayer ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const HIDE_DELAY = 2500;
|
const HIDE_DELAY = 2500;
|
||||||
|
|
||||||
interface MediaPlayerProps {
|
interface MediaPlayerProps {
|
||||||
src: string;
|
src: string;
|
||||||
kind: "audio" | "video";
|
kind: "audio" | "video";
|
||||||
mime?: string;
|
mime?: string;
|
||||||
|
autoplay?: boolean;
|
||||||
|
onPlayStateChange?: (playing: boolean) => void;
|
||||||
|
onTimeUpdate?: (time: number, duration: number) => void;
|
||||||
|
seekRef?: { current: ((t: number) => void) | null };
|
||||||
|
toggleRef?: { current: (() => void) | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
export function MediaPlayer(
|
||||||
|
{ src, kind, mime, autoplay, onPlayStateChange, onTimeUpdate, seekRef, toggleRef }:
|
||||||
|
MediaPlayerProps,
|
||||||
|
) {
|
||||||
const mediaRef = useRef<HTMLMediaElement>(null);
|
const mediaRef = useRef<HTMLMediaElement>(null);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
@@ -57,13 +141,93 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
|||||||
const [controlsVisible, setControlsVisible] = useState(true);
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// ── Callback refs (mutated in render, never trigger effects as deps) ─────────
|
||||||
|
// Updating refs in render is safe: they're only read in event handlers / effects,
|
||||||
|
// never during render. This avoids the stale-closure problem without extra effects.
|
||||||
|
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||||
|
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||||
|
onPlayStateChangeRef.current = onPlayStateChange;
|
||||||
|
onTimeUpdateRef.current = onTimeUpdate;
|
||||||
|
|
||||||
|
// Stable function refs — logic updated in render, registered once via a stable lambda.
|
||||||
|
// This avoids the "no-deps effect" anti-pattern that created brief null windows on
|
||||||
|
// every re-render (timeupdate fires 4×/s → ref nulled & re-registered each time).
|
||||||
|
const seekToFnRef = useRef((_t: number) => {});
|
||||||
|
seekToFnRef.current = (time: number) => {
|
||||||
|
setCurrent(time);
|
||||||
|
mediaRef.current!.currentTime = time;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFnRef = useRef(() => {});
|
||||||
|
toggleFnRef.current = () => {
|
||||||
|
const a = mediaRef.current!;
|
||||||
|
if (playing) {
|
||||||
|
a.pause();
|
||||||
|
setPlaying(false);
|
||||||
|
onPlayStateChangeRef.current?.(false);
|
||||||
|
} else {
|
||||||
|
a.play()
|
||||||
|
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stable wrappers used everywhere inside the component
|
||||||
|
const seekTo = (time: number) => seekToFnRef.current(time);
|
||||||
|
const toggle = () => toggleFnRef.current();
|
||||||
|
|
||||||
|
// ── Effects ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoplay) return;
|
||||||
|
mediaRef.current?.play()
|
||||||
|
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
|
||||||
|
.catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On unmount: pause and cut callbacks so stale timeupdate/ended events that fire
|
||||||
|
// between React's commit and the listener-removal effect can't reach the provider.
|
||||||
|
useEffect(() => {
|
||||||
|
const a = mediaRef.current!;
|
||||||
|
return () => {
|
||||||
|
a.pause();
|
||||||
|
onPlayStateChangeRef.current = undefined;
|
||||||
|
onTimeUpdateRef.current = undefined;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Register imperative handles into provider refs. seekRef/toggleRef are stable
|
||||||
|
// (created with useRef in PlayerProvider), so this effect runs exactly once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (seekRef) seekRef.current = (t) => seekToFnRef.current(t);
|
||||||
|
if (toggleRef) toggleRef.current = () => toggleFnRef.current();
|
||||||
|
return () => {
|
||||||
|
if (seekRef) seekRef.current = null;
|
||||||
|
if (toggleRef) toggleRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [seekRef, toggleRef]);
|
||||||
|
|
||||||
|
// Media element event listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const a = mediaRef.current!;
|
const a = mediaRef.current!;
|
||||||
const onTime = () => {
|
const onTime = () => {
|
||||||
if (!dragging) setCurrent(a.currentTime);
|
if (!dragging) {
|
||||||
|
setCurrent(a.currentTime);
|
||||||
|
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onDuration = () => {
|
||||||
|
setDuration(a.duration);
|
||||||
|
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
||||||
|
};
|
||||||
|
const onEnded = () => {
|
||||||
|
setPlaying(false);
|
||||||
|
onPlayStateChangeRef.current?.(false);
|
||||||
};
|
};
|
||||||
const onDuration = () => setDuration(a.duration);
|
|
||||||
const onEnded = () => setPlaying(false);
|
|
||||||
a.addEventListener("timeupdate", onTime);
|
a.addEventListener("timeupdate", onTime);
|
||||||
a.addEventListener("durationchange", onDuration);
|
a.addEventListener("durationchange", onDuration);
|
||||||
a.addEventListener("ended", onEnded);
|
a.addEventListener("ended", onEnded);
|
||||||
@@ -74,30 +238,18 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
|||||||
};
|
};
|
||||||
}, [dragging]);
|
}, [dragging]);
|
||||||
|
|
||||||
// Stop playback on unmount; the browser aborts network requests when the element leaves the DOM.
|
// Hide video controls after inactivity
|
||||||
useEffect(() => {
|
|
||||||
const a = mediaRef.current!;
|
|
||||||
return () => {
|
|
||||||
a.pause();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Schedule controls hide when playing; controls are always visible when paused (derived below)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (kind !== "video") return;
|
if (kind !== "video") return;
|
||||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
if (playing) {
|
if (playing) {
|
||||||
hideTimer.current = setTimeout(
|
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||||
() => setControlsVisible(false),
|
|
||||||
HIDE_DELAY,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return () => {
|
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
||||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
||||||
};
|
|
||||||
}, [playing, kind]);
|
}, [playing, kind]);
|
||||||
|
|
||||||
// Controls are always visible when paused or for audio; otherwise follow controlsVisible state
|
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const showingControls = kind !== "video" || !playing || controlsVisible;
|
const showingControls = kind !== "video" || !playing || controlsVisible;
|
||||||
|
|
||||||
const showControlsTemporarily = () => {
|
const showControlsTemporarily = () => {
|
||||||
@@ -105,26 +257,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
|||||||
setControlsVisible(true);
|
setControlsVisible(true);
|
||||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
if (playing) {
|
if (playing) {
|
||||||
hideTimer.current = setTimeout(
|
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||||
() => setControlsVisible(false),
|
|
||||||
HIDE_DELAY,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggle = () => {
|
const seek = (e: React.ChangeEvent<HTMLInputElement>) => seekTo(Number(e.target.value));
|
||||||
const a = mediaRef.current!;
|
|
||||||
if (playing) {
|
|
||||||
a.pause();
|
|
||||||
setPlaying(false);
|
|
||||||
} else a.play().then(() => setPlaying(true)).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = Number(e.target.value);
|
|
||||||
setCurrent(v);
|
|
||||||
mediaRef.current!.currentTime = v;
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const v = Number(e.target.value);
|
const v = Number(e.target.value);
|
||||||
@@ -148,24 +285,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
|||||||
|
|
||||||
const progress = duration > 0 ? current / duration : 0;
|
const progress = duration > 0 ? current / duration : 0;
|
||||||
|
|
||||||
const controls = (
|
const track = kind === "audio"
|
||||||
<>
|
? <Waveform src={src} current={current} duration={duration} onSeek={seekTo} />
|
||||||
<button
|
: (
|
||||||
type="button"
|
|
||||||
className="audio-player-btn"
|
|
||||||
onClick={toggle}
|
|
||||||
aria-label={playing ? "Pause" : "Play"}
|
|
||||||
>
|
|
||||||
{playing ? <IconPause /> : <IconPlay />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="audio-player-time">{fmt(current)}</span>
|
|
||||||
|
|
||||||
<div className="audio-player-track">
|
<div className="audio-player-track">
|
||||||
<div
|
<div className="audio-player-fill" style={{ width: `${progress * 100}%` }} />
|
||||||
className="audio-player-fill"
|
|
||||||
style={{ width: `${progress * 100}%` }}
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="audio-player-range"
|
className="audio-player-range"
|
||||||
@@ -179,6 +303,22 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
|||||||
aria-label="Seek"
|
aria-label="Seek"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const controls = (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="audio-player-btn"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={playing ? "Pause" : "Play"}
|
||||||
|
>
|
||||||
|
{playing ? <IconPause /> : <IconPlay />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="audio-player-time">{fmt(current)}</span>
|
||||||
|
|
||||||
|
{track}
|
||||||
|
|
||||||
<span className="audio-player-time">{fmt(duration)}</span>
|
<span className="audio-player-time">{fmt(duration)}</span>
|
||||||
|
|
||||||
@@ -225,9 +365,7 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
|||||||
if (kind === "video") {
|
if (kind === "video") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`video-player${
|
className={`video-player${showingControls ? " video-player--controls-visible" : ""}`}
|
||||||
showingControls ? " video-player--controls-visible" : ""
|
|
||||||
}`}
|
|
||||||
onMouseMove={showControlsTemporarily}
|
onMouseMove={showControlsTemporarily}
|
||||||
onMouseLeave={() => playing && setControlsVisible(false)}
|
onMouseLeave={() => playing && setControlsVisible(false)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type ReactNode, useEffect, useRef } from "react";
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -41,7 +42,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="modal-close-btn"
|
className="modal-close-btn"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label={t`Close`}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
@@ -18,12 +19,15 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
if (animatingRef.current) return;
|
if (animatingRef.current) return;
|
||||||
animatingRef.current = true;
|
animatingRef.current = true;
|
||||||
setRinging(true);
|
const tStart = setTimeout(() => setRinging(true), 0);
|
||||||
const t = setTimeout(() => {
|
const tEnd = setTimeout(() => {
|
||||||
setRinging(false);
|
setRinging(false);
|
||||||
animatingRef.current = false;
|
animatingRef.current = false;
|
||||||
}, 700);
|
}, 700);
|
||||||
return () => clearTimeout(t);
|
return () => {
|
||||||
|
clearTimeout(tStart);
|
||||||
|
clearTimeout(tEnd);
|
||||||
|
};
|
||||||
}, [lastNotification]);
|
}, [lastNotification]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,11 +37,9 @@ export function NotificationBell() {
|
|||||||
ringing ? " notification-bell--ringing" : ""
|
ringing ? " notification-bell--ringing" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => navigate("/notifications")}
|
onClick={() => navigate("/notifications")}
|
||||||
aria-label={`Notifications${
|
aria-label={unreadNotificationCount > 0
|
||||||
unreadNotificationCount > 0
|
? t`Notifications (${unreadNotificationCount} unread)`
|
||||||
? ` (${unreadNotificationCount} unread)`
|
: t`Notifications`}
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span className="notification-bell-icon">🔔</span>
|
<span className="notification-bell-icon">🔔</span>
|
||||||
{unreadNotificationCount > 0 && (
|
{unreadNotificationCount > 0 && (
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import { PageShell } from "./PageShell.tsx";
|
import { PageShell } from "./PageShell.tsx";
|
||||||
import { ErrorCard } from "./ErrorCard.tsx";
|
import { ErrorCard } from "./ErrorCard.tsx";
|
||||||
|
|
||||||
export function PageError(
|
export function PageError(
|
||||||
{ title = "Something went wrong", message, actions }: {
|
{ title, message, actions }: {
|
||||||
title?: string;
|
title?: string;
|
||||||
message: string;
|
message: string;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
const resolvedTitle = title ?? t`Something went wrong`;
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="page-error-wrap">
|
<div className="page-error-wrap">
|
||||||
<ErrorCard title={title} message={message} actions={actions} />
|
<ErrorCard title={resolvedTitle} message={message} actions={actions} />
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Playlist } from "../model.ts";
|
import type { Playlist } from "../model.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
@@ -66,7 +68,7 @@ export function PlaylistCard(
|
|||||||
playlist.isPublic ? "" : " playlist-badge--private"
|
playlist.isPublic ? "" : " playlist-badge--private"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{playlist.isPublic ? "public" : "private"}
|
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
|
||||||
</span>
|
</span>
|
||||||
{playlist.ownerUsername && !isOwner && (
|
{playlist.ownerUsername && !isOwner && (
|
||||||
<Link
|
<Link
|
||||||
@@ -79,8 +81,11 @@ export function PlaylistCard(
|
|||||||
)}
|
)}
|
||||||
{playlist.dumpCount !== undefined && (
|
{playlist.dumpCount !== undefined && (
|
||||||
<span className="playlist-card-count">
|
<span className="playlist-card-count">
|
||||||
{playlist.dumpCount}{" "}
|
<Plural
|
||||||
{playlist.dumpCount === 1 ? "dump" : "dumps"}
|
value={playlist.dumpCount}
|
||||||
|
one="# dump"
|
||||||
|
other="# dumps"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||||
@@ -99,7 +104,7 @@ export function PlaylistCard(
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete();
|
onDelete();
|
||||||
}}
|
}}
|
||||||
aria-label="Delete playlist"
|
aria-label={t`Delete playlist`}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
|
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
|
||||||
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
|
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
|
||||||
@@ -54,7 +56,7 @@ export function PlaylistCreateForm(
|
|||||||
}
|
}
|
||||||
onCreated(playlist);
|
onCreated(playlist);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to create playlist");
|
setError(t`Failed to create playlist`);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -64,14 +66,14 @@ export function PlaylistCreateForm(
|
|||||||
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Title"
|
placeholder={t`Title`}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
placeholder="Description (optional)"
|
placeholder={t`Description (optional)`}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={setDescription}
|
onChange={setDescription}
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -82,17 +84,17 @@ export function PlaylistCreateForm(
|
|||||||
className={isPublic ? "active" : ""}
|
className={isPublic ? "active" : ""}
|
||||||
onClick={() => setIsPublic(true)}
|
onClick={() => setIsPublic(true)}
|
||||||
>
|
>
|
||||||
Public
|
<Trans>Public</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={!isPublic ? "active" : ""}
|
className={!isPublic ? "active" : ""}
|
||||||
onClick={() => setIsPublic(false)}
|
onClick={() => setIsPublic(false)}
|
||||||
>
|
>
|
||||||
Private
|
<Trans>Private</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <ErrorCard title="Failed to create playlist" message={error} />}
|
{error && <ErrorCard title={t`Failed to create playlist`} message={error} />}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<div className="form-actions-right">
|
<div className="form-actions-right">
|
||||||
<button
|
<button
|
||||||
@@ -100,14 +102,18 @@ export function PlaylistCreateForm(
|
|||||||
className="form-cancel"
|
className="form-cancel"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{submitting ? "Creating…" : dumpId ? "Create & Add" : "Create"}
|
{submitting
|
||||||
|
? <Trans>Creating…</Trans>
|
||||||
|
: dumpId
|
||||||
|
? <Trans>Create & Add</Trans>
|
||||||
|
: <Trans>Create</Trans>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import type { PlaylistMembership } from "../model.ts";
|
import type { PlaylistMembership } from "../model.ts";
|
||||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||||
|
|
||||||
@@ -22,9 +23,9 @@ export function PlaylistMembershipPanel({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{loading
|
{loading
|
||||||
? <p className="page-loading">Loading…</p>
|
? <p className="page-loading"><Trans>Loading…</Trans></p>
|
||||||
: memberships.length === 0 && !showNewForm
|
: memberships.length === 0 && !showNewForm
|
||||||
? <p className="empty-state">No playlists yet.</p>
|
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
|
||||||
: (
|
: (
|
||||||
<ul className="playlist-membership-list">
|
<ul className="playlist-membership-list">
|
||||||
{memberships.map((m) => (
|
{memberships.map((m) => (
|
||||||
@@ -43,7 +44,7 @@ export function PlaylistMembershipPanel({
|
|||||||
</span>
|
</span>
|
||||||
{!m.playlist.isPublic && (
|
{!m.playlist.isPublic && (
|
||||||
<span className="playlist-badge playlist-badge--private">
|
<span className="playlist-badge playlist-badge--private">
|
||||||
private
|
<Trans>private</Trans>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
@@ -68,7 +69,7 @@ export function PlaylistMembershipPanel({
|
|||||||
className="modal-new-playlist-toggle"
|
className="modal-new-playlist-toggle"
|
||||||
onClick={() => setShowNewForm(true)}
|
onClick={() => setShowNewForm(true)}
|
||||||
>
|
>
|
||||||
+ New playlist
|
<Trans>+ New playlist</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default function RichContentCard(
|
|||||||
className="rich-content-thumbnail-btn"
|
className="rich-content-thumbnail-btn"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
play({
|
play({
|
||||||
|
kind: "embed",
|
||||||
embedUrl: richContent.embedUrl!,
|
embedUrl: richContent.embedUrl!,
|
||||||
title: richContent.title,
|
title: richContent.title,
|
||||||
type: richContent.type,
|
type: richContent.type,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
collapsible?: boolean;
|
collapsible?: boolean;
|
||||||
@@ -15,7 +16,7 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (collapsible && expanded) inputRef.current?.focus();
|
if (collapsible && expanded) inputRef.current?.focus();
|
||||||
}, [expanded]);
|
}, [expanded, collapsible]);
|
||||||
|
|
||||||
function handleIconClick() {
|
function handleIconClick() {
|
||||||
if (!collapsible) return;
|
if (!collapsible) return;
|
||||||
@@ -57,17 +58,17 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
className="search-bar-input"
|
className="search-bar-input"
|
||||||
placeholder="Search dumps, users, playlists…"
|
placeholder={t`Search dumps, users, playlists…`}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
aria-label="Search"
|
aria-label={t`Search`}
|
||||||
tabIndex={expanded ? 0 : -1}
|
tabIndex={expanded ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type={expanded && !collapsible ? "submit" : "button"}
|
type={expanded && !collapsible ? "submit" : "button"}
|
||||||
className="search-bar-btn"
|
className="search-bar-btn"
|
||||||
aria-label={expanded ? "Submit search" : "Open search"}
|
aria-label={expanded ? t`Submit search` : t`Open search`}
|
||||||
onClick={collapsible ? handleIconClick : undefined}
|
onClick={collapsible ? handleIconClick : undefined}
|
||||||
>
|
>
|
||||||
🔍
|
🔍
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { EmojiPicker } from "frimousse";
|
import { EmojiPicker } from "frimousse";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { MentionDropdown } from "./MentionDropdown.tsx";
|
import { MentionDropdown } from "./MentionDropdown.tsx";
|
||||||
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
||||||
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
||||||
@@ -269,8 +270,8 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<EmojiPicker.Loading>Loading…</EmojiPicker.Loading>
|
<EmojiPicker.Loading><Trans>Loading…</Trans></EmojiPicker.Loading>
|
||||||
<EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>
|
<EmojiPicker.Empty><Trans>No emoji found.</Trans></EmojiPicker.Empty>
|
||||||
<EmojiPicker.List />
|
<EmojiPicker.List />
|
||||||
</EmojiPicker.Viewport>
|
</EmojiPicker.Viewport>
|
||||||
</EmojiPicker.Root>
|
</EmojiPicker.Root>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { Avatar } from "./Avatar.tsx";
|
import { Avatar } from "./Avatar.tsx";
|
||||||
import type { User } from "../model.ts";
|
import type { User } from "../model.ts";
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ export function UserMenu({ user }: { user: User }) {
|
|||||||
className="user-menu-trigger"
|
className="user-menu-trigger"
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label="User menu"
|
aria-label={t`User menu`}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
@@ -57,7 +59,7 @@ export function UserMenu({ user }: { user: User }) {
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Playlists
|
<Trans>Playlists</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
3
src/config/feedTabs.ts
Normal file
3
src/config/feedTabs.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const FEED_TABS = ["hot", "new", "journal", "followed"] as const;
|
||||||
|
export type FeedTab = (typeof FEED_TABS)[number];
|
||||||
|
export const VALID_TABS = new Set<string>(FEED_TABS);
|
||||||
@@ -1,19 +1,43 @@
|
|||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
export interface PlayerItem {
|
export type PlayerItem =
|
||||||
embedUrl: string;
|
| { kind: "embed"; embedUrl: string; title?: string; type: string }
|
||||||
title?: string;
|
| { kind: "file"; fileUrl: string; mimeType: string; title?: string };
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlayerContextValue {
|
export interface PlayerContextValue {
|
||||||
|
// Playback state — readable by any consumer
|
||||||
current: PlayerItem | null;
|
current: PlayerItem | null;
|
||||||
|
playing: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
|
||||||
|
// Control — callable by any consumer
|
||||||
play(item: PlayerItem): void;
|
play(item: PlayerItem): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
|
seekTo(time: number): void;
|
||||||
|
togglePlay(): void;
|
||||||
|
|
||||||
|
// Internal: GlobalPlayer registers MediaPlayer's imperative handles here
|
||||||
|
// so seekTo / togglePlay can reach into the actual media element.
|
||||||
|
seekRef: { current: ((t: number) => void) | null };
|
||||||
|
toggleRef: { current: (() => void) | null };
|
||||||
|
|
||||||
|
// Internal: GlobalPlayer calls these to push state back into the provider
|
||||||
|
onPlayStateChange(playing: boolean): void;
|
||||||
|
onTimeUpdate(time: number, duration: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerContext = createContext<PlayerContextValue>({
|
export const PlayerContext = createContext<PlayerContextValue>({
|
||||||
current: null,
|
current: null,
|
||||||
|
playing: false,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
play: () => {},
|
play: () => {},
|
||||||
stop: () => {},
|
stop: () => {},
|
||||||
|
seekTo: () => {},
|
||||||
|
togglePlay: () => {},
|
||||||
|
seekRef: { current: null },
|
||||||
|
toggleRef: { current: null },
|
||||||
|
onPlayStateChange: () => {},
|
||||||
|
onTimeUpdate: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,80 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
|
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
|
||||||
|
|
||||||
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [current, setCurrent] = useState<PlayerItem | null>(null);
|
const [current, setCurrent] = useState<PlayerItem | null>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
const play = setCurrent;
|
// GlobalPlayer registers the active MediaPlayer's imperative handles here
|
||||||
const stop = useCallback(() => setCurrent(null), []);
|
const seekRef = useRef<((t: number) => void) | null>(null);
|
||||||
const value = useMemo(() => ({ current, play, stop }), [current, play, stop]);
|
const toggleRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// Suppresses stale timeupdate callbacks that fire between play() and the old
|
||||||
|
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
|
||||||
|
const suppressUpdates = useRef(false);
|
||||||
|
|
||||||
|
const play = useCallback((item: PlayerItem) => {
|
||||||
|
suppressUpdates.current = true;
|
||||||
|
setCurrent(item);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
setCurrent(null);
|
||||||
|
setPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seekTo = useCallback((t: number) => {
|
||||||
|
seekRef.current?.(t);
|
||||||
|
setCurrentTime(t); // optimistic — prevents waveform jitter before timeupdate fires
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
toggleRef.current?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPlayStateChange = useCallback((p: boolean) => {
|
||||||
|
if (p) suppressUpdates.current = false;
|
||||||
|
setPlaying(p);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTimeUpdate = useCallback((t: number, d: number) => {
|
||||||
|
if (suppressUpdates.current) return;
|
||||||
|
setCurrentTime(t);
|
||||||
|
setDuration(d);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
current,
|
||||||
|
playing,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
play,
|
||||||
|
stop,
|
||||||
|
seekTo,
|
||||||
|
togglePlay,
|
||||||
|
seekRef,
|
||||||
|
toggleRef,
|
||||||
|
onPlayStateChange,
|
||||||
|
onTimeUpdate,
|
||||||
|
}), [
|
||||||
|
current,
|
||||||
|
playing,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
play,
|
||||||
|
stop,
|
||||||
|
seekTo,
|
||||||
|
togglePlay,
|
||||||
|
onPlayStateChange,
|
||||||
|
onTimeUpdate,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerContext.Provider value={value}>
|
<PlayerContext.Provider value={value}>
|
||||||
|
|||||||
@@ -30,12 +30,11 @@ import {
|
|||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
deserializePublicUser,
|
deserializePublicUser,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
|
||||||
interface WSProviderProps {
|
interface WSProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
token: string | null;
|
|
||||||
userId: string | null;
|
|
||||||
onForceLogout?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_BACKOFF = 30_000;
|
const MAX_BACKOFF = 30_000;
|
||||||
@@ -61,13 +60,23 @@ function parseWSMessage(data: string): IncomingWSMessage | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WSProvider(
|
export function WSProvider({ children }: WSProviderProps) {
|
||||||
{ children, token, userId, onForceLogout }: WSProviderProps,
|
const { token, user, logout } = useAuth();
|
||||||
) {
|
const userId = user?.id ?? null;
|
||||||
const [wsStatus, setWSStatus] = useState<
|
const [wsStatus, setWSStatus] = useState<
|
||||||
"connecting" | "connected" | "disconnected"
|
"connecting" | "connected" | "disconnected"
|
||||||
>("connecting");
|
>("connecting");
|
||||||
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset status to "connecting" during render when token changes, rather than
|
||||||
|
// inside the effect (which would cause a cascading re-render).
|
||||||
|
const [prevToken, setPrevToken] = useState(token);
|
||||||
|
if (prevToken !== token) {
|
||||||
|
setPrevToken(token);
|
||||||
|
setWSStatus("connecting");
|
||||||
|
setWSErrorMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||||
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||||
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
||||||
@@ -94,10 +103,14 @@ export function WSProvider(
|
|||||||
const voteCountsRef = useRef(voteCounts);
|
const voteCountsRef = useRef(voteCounts);
|
||||||
const myVotesRef = useRef(myVotes);
|
const myVotesRef = useRef(myVotes);
|
||||||
const userIdRef = useRef(userId);
|
const userIdRef = useRef(userId);
|
||||||
|
// Stable ref for logout so the effect doesn't reconnect when the function
|
||||||
|
// reference changes on re-renders.
|
||||||
|
const onForceLogoutRef = useRef(logout);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
voteCountsRef.current = voteCounts;
|
voteCountsRef.current = voteCounts;
|
||||||
myVotesRef.current = myVotes;
|
myVotesRef.current = myVotes;
|
||||||
userIdRef.current = userId;
|
userIdRef.current = userId;
|
||||||
|
onForceLogoutRef.current = logout;
|
||||||
});
|
});
|
||||||
|
|
||||||
const socketRef = useRef<WebSocket | null>(null);
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
@@ -139,9 +152,6 @@ export function WSProvider(
|
|||||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let everConnected = false;
|
let everConnected = false;
|
||||||
|
|
||||||
setWSStatus("connecting");
|
|
||||||
setWSErrorMessage(null);
|
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
|
||||||
@@ -155,7 +165,7 @@ export function WSProvider(
|
|||||||
if (ws.readyState !== WebSocket.CONNECTING) return;
|
if (ws.readyState !== WebSocket.CONNECTING) return;
|
||||||
setWSStatus("disconnected");
|
setWSStatus("disconnected");
|
||||||
setWSErrorMessage(
|
setWSErrorMessage(
|
||||||
"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.",
|
t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
|
||||||
);
|
);
|
||||||
ws.close();
|
ws.close();
|
||||||
}, CONNECT_TIMEOUT);
|
}, CONNECT_TIMEOUT);
|
||||||
@@ -327,7 +337,7 @@ export function WSProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "force_logout":
|
case "force_logout":
|
||||||
onForceLogout?.();
|
onForceLogoutRef.current();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
@@ -346,8 +356,8 @@ export function WSProvider(
|
|||||||
setWSStatus("disconnected");
|
setWSStatus("disconnected");
|
||||||
setWSErrorMessage(
|
setWSErrorMessage(
|
||||||
everConnected
|
everConnected
|
||||||
? "Live updates are temporarily disconnected. Trying to reconnect..."
|
? t`Live updates are temporarily disconnected. Trying to reconnect…`
|
||||||
: "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.",
|
: t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
|
||||||
);
|
);
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
backoff = Math.min(backoff * 2, MAX_BACKOFF);
|
backoff = Math.min(backoff * 2, MAX_BACKOFF);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type RefObject, useCallback, useRef, useState } from "react";
|
import { type RefObject, useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
// Trigger: ':' not preceded by a word character, followed by 1+ word chars
|
// Trigger: ':' not preceded by a word character, followed by 1+ word chars
|
||||||
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z0-9_+\-]{1,})$/;
|
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z0-9_+-]{1,})$/;
|
||||||
|
|
||||||
interface EmojiTriggerState {
|
interface EmojiTriggerState {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export function useUserDumpFeed(
|
|||||||
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
|
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
|
||||||
|
|
||||||
const [state, setState] = useState<State>({ status: "loading" });
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
const [prevUsername, setPrevUsername] = useState(username);
|
||||||
|
if (prevUsername !== username) {
|
||||||
|
setPrevUsername(username);
|
||||||
|
setState({ status: "loading" });
|
||||||
|
}
|
||||||
|
|
||||||
const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) });
|
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) });
|
||||||
@@ -70,7 +75,6 @@ export function useUserDumpFeed(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -126,7 +130,7 @@ export function useUserDumpFeed(
|
|||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [username, endpoint]);
|
}, [username, endpoint, cached, token]);
|
||||||
|
|
||||||
const { onItemsAppended } = options ?? {};
|
const { onItemsAppended } = options ?? {};
|
||||||
|
|
||||||
|
|||||||
20
src/i18n.ts
Normal file
20
src/i18n.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
|
||||||
|
const SUPPORTED = ["en", "fr"] as const;
|
||||||
|
type Locale = (typeof SUPPORTED)[number];
|
||||||
|
|
||||||
|
function detectLocale(): Locale {
|
||||||
|
const stored = localStorage.getItem("locale");
|
||||||
|
if (stored && (SUPPORTED as readonly string[]).includes(stored)) return stored as Locale;
|
||||||
|
return navigator.language.startsWith("fr") ? "fr" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCatalog(locale: Locale = detectLocale()) {
|
||||||
|
const { messages } = await import(`./locales/${locale}.po`);
|
||||||
|
i18n.load(locale, messages);
|
||||||
|
i18n.activate(locale);
|
||||||
|
localStorage.setItem("locale", locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { i18n };
|
||||||
|
export type { Locale };
|
||||||
1
src/locales/en.js
Normal file
1
src/locales/en.js
Normal file
File diff suppressed because one or more lines are too long
1
src/locales/en.mjs
Normal file
1
src/locales/en.mjs
Normal file
File diff suppressed because one or more lines are too long
956
src/locales/en.po
Normal file
956
src/locales/en.po
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"POT-Creation-Date: 2026-03-31 06:22+0000\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"X-Generator: @lingui/cli\n"
|
||||||
|
"Language: en\n"
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"PO-Revision-Date: \n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:170
|
||||||
|
msgid "[deleted]"
|
||||||
|
msgstr "[deleted]"
|
||||||
|
|
||||||
|
#. placeholder {0}: dump.commentCount
|
||||||
|
#: src/components/DumpCard.tsx:82
|
||||||
|
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||||
|
msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: playlist.dumpCount
|
||||||
|
#: src/components/PlaylistCard.tsx:84
|
||||||
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
|
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: VALIDATION.USERNAME_MIN
|
||||||
|
#. placeholder {1}: VALIDATION.USERNAME_MAX
|
||||||
|
#: src/pages/UserRegister.tsx:128
|
||||||
|
msgid "{0}–{1} characters: letters, numbers, or underscores"
|
||||||
|
msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:184
|
||||||
|
msgid "{days}d ago"
|
||||||
|
msgstr "{days}d ago"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:182
|
||||||
|
msgid "{hrs}h ago"
|
||||||
|
msgstr "{hrs}h ago"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:176
|
||||||
|
msgid "{label} ({count})"
|
||||||
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:180
|
||||||
|
msgid "{mins}m ago"
|
||||||
|
msgstr "{mins}m ago"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:436
|
||||||
|
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
|
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:605
|
||||||
|
#: src/pages/UserPublicProfile.tsx:606
|
||||||
|
msgid "← Back"
|
||||||
|
msgstr "← Back"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:216
|
||||||
|
#: src/pages/Dump.tsx:318
|
||||||
|
#: src/pages/DumpEdit.tsx:166
|
||||||
|
msgid "← Back to all dumps"
|
||||||
|
msgstr "← Back to all dumps"
|
||||||
|
|
||||||
|
#: src/pages/UserDumps.tsx:61
|
||||||
|
#: src/pages/UserPlaylists.tsx:352
|
||||||
|
#: src/pages/UserUpvoted.tsx:130
|
||||||
|
msgid "← Back to profile"
|
||||||
|
msgstr "← Back to profile"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:90
|
||||||
|
msgid "+ Invite someone"
|
||||||
|
msgstr "+ Invite someone"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:63
|
||||||
|
msgid "+ New"
|
||||||
|
msgstr "+ New"
|
||||||
|
|
||||||
|
#: src/pages/UserDumps.tsx:82
|
||||||
|
#: src/pages/UserPublicProfile.tsx:922
|
||||||
|
msgid "+ New dump"
|
||||||
|
msgstr "+ New dump"
|
||||||
|
|
||||||
|
#: src/components/PlaylistMembershipPanel.tsx:72
|
||||||
|
msgid "+ New playlist"
|
||||||
|
msgstr "+ New playlist"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:248
|
||||||
|
msgid "+ Playlist"
|
||||||
|
msgstr "+ Playlist"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.followerUsername
|
||||||
|
#. placeholder {1}: d.playlistTitle
|
||||||
|
#: src/pages/Notifications.tsx:124
|
||||||
|
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.mentionerUsername
|
||||||
|
#: src/pages/Notifications.tsx:166
|
||||||
|
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||||
|
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.dumperUsername
|
||||||
|
#. placeholder {1}: d.dumpTitle
|
||||||
|
#: src/pages/Notifications.tsx:134
|
||||||
|
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0> posted <1>{1}</1>"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.followerUsername
|
||||||
|
#: src/pages/Notifications.tsx:115
|
||||||
|
msgid "<0>{0}</0> started following you"
|
||||||
|
msgstr "<0>{0}</0> started following you"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.voterUsername
|
||||||
|
#. placeholder {1}: d.dumpTitle
|
||||||
|
#: src/pages/Notifications.tsx:154
|
||||||
|
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.dumpTitle
|
||||||
|
#. placeholder {1}: d.playlistTitle
|
||||||
|
#: src/pages/Notifications.tsx:144
|
||||||
|
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:164
|
||||||
|
msgid "a comment"
|
||||||
|
msgstr "a comment"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:164
|
||||||
|
msgid "a post"
|
||||||
|
msgstr "a post"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:802
|
||||||
|
msgid "Add a bio…"
|
||||||
|
msgstr "Add a bio…"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:456
|
||||||
|
msgid "Add a comment…"
|
||||||
|
msgstr "Add a comment…"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:718
|
||||||
|
msgid "Add email…"
|
||||||
|
msgstr "Add email…"
|
||||||
|
|
||||||
|
#: src/components/AddToPlaylistModal.tsx:64
|
||||||
|
#: src/components/DumpCreateModal.tsx:262
|
||||||
|
msgid "Add to playlist"
|
||||||
|
msgstr "Add to playlist"
|
||||||
|
|
||||||
|
#: api/auth:
|
||||||
|
#~ msgid "Admin access required"
|
||||||
|
#~ msgstr "Admin access required"
|
||||||
|
|
||||||
|
#. placeholder {0}: dumps.length
|
||||||
|
#: src/pages/UserDumps.tsx:114
|
||||||
|
msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||||
|
msgstr "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||||
|
|
||||||
|
#. placeholder {0}: votes.length
|
||||||
|
#: src/pages/UserUpvoted.tsx:184
|
||||||
|
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||||
|
msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:160
|
||||||
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
|
msgstr "Already have an account? <0>Log in</0>"
|
||||||
|
|
||||||
|
#: src/contexts/WSProvider.tsx:168
|
||||||
|
#: src/contexts/WSProvider.tsx:360
|
||||||
|
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
|
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:268
|
||||||
|
#: src/components/CommentThread.tsx:353
|
||||||
|
#: src/components/CommentThread.tsx:483
|
||||||
|
#: src/components/ConfirmModal.tsx:32
|
||||||
|
#: src/components/DumpCreateModal.tsx:394
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:105
|
||||||
|
#: src/pages/DumpEdit.tsx:288
|
||||||
|
#: src/pages/PlaylistDetail.tsx:672
|
||||||
|
#: src/pages/UserPublicProfile.tsx:700
|
||||||
|
#: src/pages/UserPublicProfile.tsx:773
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr "Cancel"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:848
|
||||||
|
msgid "Cancel removal"
|
||||||
|
msgstr "Cancel removal"
|
||||||
|
|
||||||
|
#: api/comments:
|
||||||
|
#~ msgid "Cannot edit a deleted comment"
|
||||||
|
#~ msgstr "Cannot edit a deleted comment"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:633
|
||||||
|
msgid "Change avatar"
|
||||||
|
msgstr "Change avatar"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:94
|
||||||
|
msgid "Checking invite…"
|
||||||
|
msgstr "Checking invite…"
|
||||||
|
|
||||||
|
#: src/components/Modal.tsx:45
|
||||||
|
msgid "Close"
|
||||||
|
msgstr "Close"
|
||||||
|
|
||||||
|
#: api/comments:
|
||||||
|
#~ msgid "Comment not found"
|
||||||
|
#~ msgstr "Comment not found"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:81
|
||||||
|
msgid "Copied!"
|
||||||
|
msgstr "Copied!"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:81
|
||||||
|
msgid "Copy"
|
||||||
|
msgstr "Copy"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:108
|
||||||
|
#: src/components/CommentThread.tsx:147
|
||||||
|
#: src/components/CommentThread.tsx:425
|
||||||
|
msgid "Could not reach the server. Please try again."
|
||||||
|
msgstr "Could not reach the server. Please try again."
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:116
|
||||||
|
msgid "Create"
|
||||||
|
msgstr "Create"
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:115
|
||||||
|
msgid "Create & Add"
|
||||||
|
msgstr "Create & Add"
|
||||||
|
|
||||||
|
#. placeholder {0}: created.items.length
|
||||||
|
#. placeholder {1}: created.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPlaylists.tsx:386
|
||||||
|
msgid "Created ({0}{1})"
|
||||||
|
msgstr "Created ({0}{1})"
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:113
|
||||||
|
msgid "Creating…"
|
||||||
|
msgstr "Creating…"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:306
|
||||||
|
#: src/components/CommentThread.tsx:312
|
||||||
|
#: src/components/ConfirmModal.tsx:16
|
||||||
|
#: src/pages/PlaylistDetail.tsx:679
|
||||||
|
msgid "Delete"
|
||||||
|
msgstr "Delete"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:284
|
||||||
|
#: src/pages/DumpEdit.tsx:300
|
||||||
|
msgid "Delete dump"
|
||||||
|
msgstr "Delete dump"
|
||||||
|
|
||||||
|
#: src/components/PlaylistCard.tsx:107
|
||||||
|
#: src/pages/PlaylistDetail.tsx:861
|
||||||
|
#: src/pages/UserPlaylists.tsx:443
|
||||||
|
msgid "Delete playlist"
|
||||||
|
msgstr "Delete playlist"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:311
|
||||||
|
msgid "Delete this comment?"
|
||||||
|
msgstr "Delete this comment?"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:299
|
||||||
|
msgid "Delete this dump? This cannot be undone."
|
||||||
|
msgstr "Delete this dump? This cannot be undone."
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:860
|
||||||
|
#: src/pages/UserPlaylists.tsx:442
|
||||||
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
|
msgstr "Delete this playlist? This cannot be undone."
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:76
|
||||||
|
#: src/pages/PlaylistDetail.tsx:710
|
||||||
|
msgid "Description (optional)"
|
||||||
|
msgstr "Description (optional)"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:439
|
||||||
|
msgid "Done"
|
||||||
|
msgstr "Done"
|
||||||
|
|
||||||
|
#: src/components/FileDropZone.tsx:32
|
||||||
|
msgid "Drop a file here"
|
||||||
|
msgstr "Drop a file here"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:242
|
||||||
|
msgid "Drop a replacement here"
|
||||||
|
msgstr "Drop a replacement here"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:405
|
||||||
|
msgid "Dump it"
|
||||||
|
msgstr "Dump it"
|
||||||
|
|
||||||
|
#: api/dumps:
|
||||||
|
#~ msgid "Dump not found"
|
||||||
|
#~ msgstr "Dump not found"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:416
|
||||||
|
msgid "Dumped!"
|
||||||
|
msgstr "Dumped!"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:172
|
||||||
|
#: src/pages/UserDumps.tsx:75
|
||||||
|
msgid "Dumps"
|
||||||
|
msgstr "Dumps"
|
||||||
|
|
||||||
|
#. placeholder {0}: dumps.items.length
|
||||||
|
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPublicProfile.tsx:817
|
||||||
|
msgid "Dumps ({0}{1})"
|
||||||
|
msgstr "Dumps ({0}{1})"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:341
|
||||||
|
msgid "Earlier"
|
||||||
|
msgstr "Earlier"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:297
|
||||||
|
#: src/pages/Dump.tsx:315
|
||||||
|
#: src/pages/PlaylistDetail.tsx:698
|
||||||
|
msgid "Edit"
|
||||||
|
msgstr "Edit"
|
||||||
|
|
||||||
|
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||||
|
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||||
|
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
||||||
|
#: src/components/CommentThread.tsx:231
|
||||||
|
#: src/pages/Dump.tsx:276
|
||||||
|
#: src/pages/PlaylistDetail.tsx:768
|
||||||
|
msgid "edited {0}"
|
||||||
|
msgstr "edited {0}"
|
||||||
|
|
||||||
|
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||||
|
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||||
|
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
||||||
|
#: src/components/CommentThread.tsx:229
|
||||||
|
#: src/pages/Dump.tsx:274
|
||||||
|
#: src/pages/PlaylistDetail.tsx:765
|
||||||
|
msgid "Edited {0}"
|
||||||
|
msgstr "Edited {0}"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:180
|
||||||
|
msgid "Editing"
|
||||||
|
msgstr "Editing"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:135
|
||||||
|
msgid "Email address"
|
||||||
|
msgstr "Email address"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:206
|
||||||
|
msgid "Enter a query to search."
|
||||||
|
msgstr "Enter a query to search."
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:59
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:97
|
||||||
|
msgid "Failed to create playlist"
|
||||||
|
msgstr "Failed to create playlist"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:62
|
||||||
|
#: src/pages/UserPublicProfile.tsx:65
|
||||||
|
#: src/pages/UserPublicProfile.tsx:92
|
||||||
|
msgid "Failed to generate invite"
|
||||||
|
msgstr "Failed to generate invite"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:77
|
||||||
|
#: src/pages/index/HotFeed.tsx:30
|
||||||
|
#: src/pages/index/JournalFeed.tsx:42
|
||||||
|
#: src/pages/index/NewFeed.tsx:30
|
||||||
|
#: src/pages/Notifications.tsx:321
|
||||||
|
msgid "Failed to load"
|
||||||
|
msgstr "Failed to load"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:300
|
||||||
|
msgid "Failed to post"
|
||||||
|
msgstr "Failed to post"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:462
|
||||||
|
msgid "Failed to post comment"
|
||||||
|
msgstr "Failed to post comment"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:334
|
||||||
|
msgid "Failed to post reply"
|
||||||
|
msgstr "Failed to post reply"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:776
|
||||||
|
#: src/pages/UserPublicProfile.tsx:546
|
||||||
|
#: src/pages/UserPublicProfile.tsx:581
|
||||||
|
#: src/pages/UserPublicProfile.tsx:704
|
||||||
|
#: src/pages/UserPublicProfile.tsx:776
|
||||||
|
msgid "Failed to save"
|
||||||
|
msgstr "Failed to save"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:249
|
||||||
|
msgid "Failed to save edit"
|
||||||
|
msgstr "Failed to save edit"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:726
|
||||||
|
msgid "Failed to update avatar"
|
||||||
|
msgstr "Failed to update avatar"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:333
|
||||||
|
msgid "Fetching preview…"
|
||||||
|
msgstr "Fetching preview…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:403
|
||||||
|
msgid "Fetching…"
|
||||||
|
msgstr "Fetching…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:293
|
||||||
|
#: src/components/FileDropZone.tsx:31
|
||||||
|
msgid "File"
|
||||||
|
msgstr "File"
|
||||||
|
|
||||||
|
#: api/avatars:
|
||||||
|
#~ msgid "File content is not a recognised image (JPEG, PNG, GIF, WebP)"
|
||||||
|
#~ msgstr "File content is not a recognised image (JPEG, PNG, GIF, WebP)"
|
||||||
|
|
||||||
|
#: api/avatars:
|
||||||
|
#~ msgid "File too large (max 5 MB)"
|
||||||
|
#~ msgstr "File too large (max 5 MB)"
|
||||||
|
|
||||||
|
#: api/dumps:
|
||||||
|
#~ msgid "File too large (max 50 MB)"
|
||||||
|
#~ msgstr "File too large (max 50 MB)"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:187
|
||||||
|
msgid "File too large (max 50 MB)."
|
||||||
|
msgstr "File too large (max 50 MB)."
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:37
|
||||||
|
#: src/components/FollowButton.tsx:64
|
||||||
|
msgid "Follow"
|
||||||
|
msgstr "Follow"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:35
|
||||||
|
msgid "Follow {targetUsername}"
|
||||||
|
msgstr "Follow {targetUsername}"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:62
|
||||||
|
msgid "Follow playlist"
|
||||||
|
msgstr "Follow playlist"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:359
|
||||||
|
msgid "Follow some public playlists to see their dumps here."
|
||||||
|
msgstr "Follow some public playlists to see their dumps here."
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:345
|
||||||
|
msgid "Follow some users to see their dumps here."
|
||||||
|
msgstr "Follow some users to see their dumps here."
|
||||||
|
|
||||||
|
#: src/components/FeedTabBar.tsx:47
|
||||||
|
msgid "Followed"
|
||||||
|
msgstr "Followed"
|
||||||
|
|
||||||
|
#. placeholder {0}: followed.items.length
|
||||||
|
#. placeholder {1}: followed.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPlaylists.tsx:416
|
||||||
|
msgid "Followed ({0}{1})"
|
||||||
|
msgstr "Followed ({0}{1})"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:37
|
||||||
|
#: src/components/FollowButton.tsx:64
|
||||||
|
msgid "Following"
|
||||||
|
msgstr "Following"
|
||||||
|
|
||||||
|
#: api/playlists:
|
||||||
|
#~ msgid "Forbidden"
|
||||||
|
#~ msgstr "Forbidden"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:325
|
||||||
|
msgid "From people"
|
||||||
|
msgstr "From people"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:332
|
||||||
|
msgid "From playlists"
|
||||||
|
msgstr "From playlists"
|
||||||
|
|
||||||
|
#: src/components/FeedTabBar.tsx:25
|
||||||
|
msgid "Hot"
|
||||||
|
msgstr "Hot"
|
||||||
|
|
||||||
|
#: api/auth:
|
||||||
|
#~ msgid "Invalid email address"
|
||||||
|
#~ msgstr "Invalid email address"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:104
|
||||||
|
msgid "Invalid invite"
|
||||||
|
msgstr "Invalid invite"
|
||||||
|
|
||||||
|
#: api/invites:
|
||||||
|
#~ msgid "Invalid or expired invite"
|
||||||
|
#~ msgstr "Invalid or expired invite"
|
||||||
|
|
||||||
|
#: api/dumps:
|
||||||
|
#~ msgid "Invalid URL"
|
||||||
|
#~ msgstr "Invalid URL"
|
||||||
|
|
||||||
|
#. Backend error strings (manually maintained)
|
||||||
|
#: api/auth:
|
||||||
|
#~ msgid "Invalid username or password"
|
||||||
|
#~ msgstr "Invalid username or password"
|
||||||
|
|
||||||
|
#: api/invites:
|
||||||
|
#~ msgid "Invite already used"
|
||||||
|
#~ msgstr "Invite already used"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:651
|
||||||
|
msgid "invited by"
|
||||||
|
msgstr "invited by"
|
||||||
|
|
||||||
|
#: src/components/FeedTabBar.tsx:39
|
||||||
|
msgid "Journal"
|
||||||
|
msgstr "Journal"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:178
|
||||||
|
msgid "just now"
|
||||||
|
msgstr "just now"
|
||||||
|
|
||||||
|
#: src/contexts/WSProvider.tsx:359
|
||||||
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
|
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:79
|
||||||
|
msgid "Live updates unavailable."
|
||||||
|
msgstr "Live updates unavailable."
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:386
|
||||||
|
msgid "Load more"
|
||||||
|
msgstr "Load more"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:193
|
||||||
|
#: src/pages/DumpEdit.tsx:143
|
||||||
|
msgid "Loading dump…"
|
||||||
|
msgstr "Loading dump…"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:103
|
||||||
|
#: src/pages/index/HotFeed.tsx:52
|
||||||
|
#: src/pages/index/JournalFeed.tsx:65
|
||||||
|
#: src/pages/index/NewFeed.tsx:52
|
||||||
|
#: src/pages/Search.tsx:239
|
||||||
|
#: src/pages/UserDumps.tsx:111
|
||||||
|
#: src/pages/UserPlaylists.tsx:409
|
||||||
|
#: src/pages/UserPlaylists.tsx:436
|
||||||
|
#: src/pages/UserUpvoted.tsx:180
|
||||||
|
msgid "Loading more…"
|
||||||
|
msgstr "Loading more…"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:590
|
||||||
|
msgid "Loading playlist…"
|
||||||
|
msgstr "Loading playlist…"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:590
|
||||||
|
msgid "Loading profile…"
|
||||||
|
msgstr "Loading profile…"
|
||||||
|
|
||||||
|
#: src/components/PlaylistMembershipPanel.tsx:26
|
||||||
|
#: src/components/TextEditor.tsx:273
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:74
|
||||||
|
#: src/pages/index/HotFeed.tsx:29
|
||||||
|
#: src/pages/index/JournalFeed.tsx:41
|
||||||
|
#: src/pages/index/NewFeed.tsx:29
|
||||||
|
#: src/pages/Notifications.tsx:318
|
||||||
|
#: src/pages/Notifications.tsx:386
|
||||||
|
#: src/pages/UserDumps.tsx:50
|
||||||
|
#: src/pages/UserPlaylists.tsx:341
|
||||||
|
#: src/pages/UserUpvoted.tsx:119
|
||||||
|
msgid "Loading…"
|
||||||
|
msgstr "Loading…"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:70
|
||||||
|
#: src/pages/UserLogin.tsx:62
|
||||||
|
#: src/pages/UserLogin.tsx:91
|
||||||
|
msgid "Log in"
|
||||||
|
msgstr "Log in"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:610
|
||||||
|
#: src/pages/UserPublicProfile.tsx:738
|
||||||
|
msgid "Log out"
|
||||||
|
msgstr "Log out"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:90
|
||||||
|
msgid "Logging in…"
|
||||||
|
msgstr "Logging in…"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:65
|
||||||
|
msgid "Login failed"
|
||||||
|
msgstr "Login failed"
|
||||||
|
|
||||||
|
#: src/components/FileDropZone.tsx:141
|
||||||
|
msgid "Max 50 MB"
|
||||||
|
msgstr "Max 50 MB"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:312
|
||||||
|
msgid "new"
|
||||||
|
msgstr "new"
|
||||||
|
|
||||||
|
#: src/components/FeedTabBar.tsx:32
|
||||||
|
msgid "New"
|
||||||
|
msgstr "New"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:262
|
||||||
|
msgid "New dump"
|
||||||
|
msgstr "New dump"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:783
|
||||||
|
msgid "No dumps in this playlist yet."
|
||||||
|
msgstr "No dumps in this playlist yet."
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:220
|
||||||
|
msgid "No dumps match \"{q}\"."
|
||||||
|
msgstr "No dumps match \"{q}\"."
|
||||||
|
|
||||||
|
#: src/pages/index/HotFeed.tsx:32
|
||||||
|
#: src/pages/index/JournalFeed.tsx:44
|
||||||
|
#: src/pages/index/NewFeed.tsx:32
|
||||||
|
msgid "No dumps yet. Be the first!"
|
||||||
|
msgstr "No dumps yet. Be the first!"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:274
|
||||||
|
msgid "No emoji found."
|
||||||
|
msgstr "No emoji found."
|
||||||
|
|
||||||
|
#: src/pages/UserPlaylists.tsx:424
|
||||||
|
msgid "No followed playlists yet."
|
||||||
|
msgstr "No followed playlists yet."
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:273
|
||||||
|
msgid "No playlists match \"{q}\"."
|
||||||
|
msgstr "No playlists match \"{q}\"."
|
||||||
|
|
||||||
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
|
#: src/pages/UserPlaylists.tsx:392
|
||||||
|
#: src/pages/UserPublicProfile.tsx:865
|
||||||
|
msgid "No playlists yet."
|
||||||
|
msgstr "No playlists yet."
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:249
|
||||||
|
msgid "No users match \"{q}\"."
|
||||||
|
msgstr "No users match \"{q}\"."
|
||||||
|
|
||||||
|
#: api/auth:
|
||||||
|
#~ msgid "Not authenticated"
|
||||||
|
#~ msgstr "Not authenticated"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:327
|
||||||
|
#: src/pages/UserDumps.tsx:92
|
||||||
|
#: src/pages/UserPublicProfile.tsx:930
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1049
|
||||||
|
#: src/pages/UserUpvoted.tsx:151
|
||||||
|
msgid "Nothing here yet."
|
||||||
|
msgstr "Nothing here yet."
|
||||||
|
|
||||||
|
#: src/components/NotificationBell.tsx:42
|
||||||
|
#: src/pages/Notifications.tsx:308
|
||||||
|
msgid "Notifications"
|
||||||
|
msgstr "Notifications"
|
||||||
|
|
||||||
|
#: src/components/NotificationBell.tsx:41
|
||||||
|
msgid "Notifications ({unreadNotificationCount} unread)"
|
||||||
|
msgstr "Notifications ({unreadNotificationCount} unread)"
|
||||||
|
|
||||||
|
#: src/components/SearchBar.tsx:71
|
||||||
|
msgid "Open search"
|
||||||
|
msgstr "Open search"
|
||||||
|
|
||||||
|
#: src/components/FileDropZone.tsx:139
|
||||||
|
msgid "or <0>browse files</0>"
|
||||||
|
msgstr "or <0>browse files</0>"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:80
|
||||||
|
msgid "Password"
|
||||||
|
msgstr "Password"
|
||||||
|
|
||||||
|
#. placeholder {0}: VALIDATION.PASSWORD_MIN
|
||||||
|
#: src/pages/UserRegister.tsx:142
|
||||||
|
msgid "Password (min. {0} characters)"
|
||||||
|
msgstr "Password (min. {0} characters)"
|
||||||
|
|
||||||
|
#: api/auth:
|
||||||
|
#~ msgid "Password must be at least 8 characters"
|
||||||
|
#~ msgstr "Password must be at least 8 characters"
|
||||||
|
|
||||||
|
#: api/auth:
|
||||||
|
#~ msgid "Password must be at most 128 characters"
|
||||||
|
#~ msgstr "Password must be at most 128 characters"
|
||||||
|
|
||||||
|
#: api/playlists:
|
||||||
|
#~ msgid "Playlist not found"
|
||||||
|
#~ msgstr "Playlist not found"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:46
|
||||||
|
#: src/components/UserMenu.tsx:62
|
||||||
|
#: src/pages/Search.tsx:175
|
||||||
|
#: src/pages/UserPlaylists.tsx:366
|
||||||
|
msgid "Playlists"
|
||||||
|
msgstr "Playlists"
|
||||||
|
|
||||||
|
#. placeholder {0}: playlists.items.length
|
||||||
|
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPublicProfile.tsx:845
|
||||||
|
msgid "Playlists ({0}{1})"
|
||||||
|
msgstr "Playlists ({0}{1})"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:180
|
||||||
|
msgid "Please select a file."
|
||||||
|
msgstr "Please select a file."
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:472
|
||||||
|
msgid "Post comment"
|
||||||
|
msgstr "Post comment"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:342
|
||||||
|
msgid "Post reply"
|
||||||
|
msgstr "Post reply"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:342
|
||||||
|
#: src/components/CommentThread.tsx:472
|
||||||
|
msgid "Posting…"
|
||||||
|
msgstr "Posting…"
|
||||||
|
|
||||||
|
#: src/components/DumpCard.tsx:91
|
||||||
|
#: src/components/PlaylistCard.tsx:71
|
||||||
|
#: src/components/PlaylistMembershipPanel.tsx:47
|
||||||
|
#: src/pages/Dump.tsx:282
|
||||||
|
#: src/pages/PlaylistDetail.tsx:748
|
||||||
|
msgid "private"
|
||||||
|
msgstr "private"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:383
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:94
|
||||||
|
#: src/pages/DumpEdit.tsx:274
|
||||||
|
#: src/pages/PlaylistDetail.tsx:737
|
||||||
|
msgid "Private"
|
||||||
|
msgstr "Private"
|
||||||
|
|
||||||
|
#: src/components/PlaylistCard.tsx:71
|
||||||
|
#: src/pages/PlaylistDetail.tsx:748
|
||||||
|
msgid "public"
|
||||||
|
msgstr "public"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:375
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:87
|
||||||
|
#: src/pages/DumpEdit.tsx:267
|
||||||
|
#: src/pages/PlaylistDetail.tsx:730
|
||||||
|
msgid "Public"
|
||||||
|
msgstr "Public"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:206
|
||||||
|
msgid "Refresh metadata"
|
||||||
|
msgstr "Refresh metadata"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:206
|
||||||
|
msgid "Refreshing…"
|
||||||
|
msgstr "Refreshing…"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:115
|
||||||
|
#: src/pages/UserRegister.tsx:155
|
||||||
|
msgid "Register"
|
||||||
|
msgstr "Register"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:154
|
||||||
|
msgid "Registering…"
|
||||||
|
msgstr "Registering…"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:118
|
||||||
|
msgid "Registration failed"
|
||||||
|
msgstr "Registration failed"
|
||||||
|
|
||||||
|
#: src/components/FileDropZone.tsx:115
|
||||||
|
msgid "Remove file"
|
||||||
|
msgstr "Remove file"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:838
|
||||||
|
msgid "Remove from playlist"
|
||||||
|
msgstr "Remove from playlist"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:241
|
||||||
|
msgid "Replace file"
|
||||||
|
msgstr "Replace file"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:284
|
||||||
|
msgid "Reply"
|
||||||
|
msgstr "Reply"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:209
|
||||||
|
#: src/pages/DumpEdit.tsx:159
|
||||||
|
msgid "Retry"
|
||||||
|
msgstr "Retry"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:257
|
||||||
|
#: src/pages/DumpEdit.tsx:291
|
||||||
|
#: src/pages/PlaylistDetail.tsx:665
|
||||||
|
#: src/pages/UserPublicProfile.tsx:692
|
||||||
|
#: src/pages/UserPublicProfile.tsx:765
|
||||||
|
msgid "Save"
|
||||||
|
msgstr "Save"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:257
|
||||||
|
#: src/pages/PlaylistDetail.tsx:665
|
||||||
|
#: src/pages/UserPublicProfile.tsx:692
|
||||||
|
#: src/pages/UserPublicProfile.tsx:765
|
||||||
|
msgid "Saving…"
|
||||||
|
msgstr "Saving…"
|
||||||
|
|
||||||
|
#: src/components/SearchBar.tsx:65
|
||||||
|
msgid "Search"
|
||||||
|
msgstr "Search"
|
||||||
|
|
||||||
|
#: src/components/SearchBar.tsx:61
|
||||||
|
msgid "Search dumps, users, playlists…"
|
||||||
|
msgstr "Search dumps, users, playlists…"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:214
|
||||||
|
msgid "Search failed"
|
||||||
|
msgstr "Search failed"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:210
|
||||||
|
msgid "Searching…"
|
||||||
|
msgstr "Searching…"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:61
|
||||||
|
msgid "Server unreachable"
|
||||||
|
msgstr "Server unreachable"
|
||||||
|
|
||||||
|
#: src/components/PageError.tsx:13
|
||||||
|
msgid "Something went wrong"
|
||||||
|
msgstr "Something went wrong"
|
||||||
|
|
||||||
|
#: src/components/SearchBar.tsx:71
|
||||||
|
msgid "Submit search"
|
||||||
|
msgstr "Submit search"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:755
|
||||||
|
msgid "Tell people about yourself…"
|
||||||
|
msgstr "Tell people about yourself…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:363
|
||||||
|
#: src/pages/DumpEdit.tsx:256
|
||||||
|
msgid "Tell the community what makes this worth their time..."
|
||||||
|
msgstr "Tell the community what makes this worth their time..."
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:105
|
||||||
|
msgid "This invite link is missing, expired, or already used."
|
||||||
|
msgstr "This invite link is missing, expired, or already used."
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:96
|
||||||
|
msgid "This is a mirage."
|
||||||
|
msgstr "This is a mirage."
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:69
|
||||||
|
msgid "Title"
|
||||||
|
msgstr "Title"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:341
|
||||||
|
msgid "Today"
|
||||||
|
msgstr "Today"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:850
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr "Undo"
|
||||||
|
|
||||||
|
#: api/generic:
|
||||||
|
#~ msgid "Unexpected server error"
|
||||||
|
#~ msgstr "Unexpected server error"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:34
|
||||||
|
msgid "Unfollow {targetUsername}"
|
||||||
|
msgstr "Unfollow {targetUsername}"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:62
|
||||||
|
msgid "Unfollow playlist"
|
||||||
|
msgstr "Unfollow playlist"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:515
|
||||||
|
msgid "Upload failed"
|
||||||
|
msgstr "Upload failed"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:404
|
||||||
|
msgid "Uploading…"
|
||||||
|
msgstr "Uploading…"
|
||||||
|
|
||||||
|
#: src/pages/UserUpvoted.tsx:147
|
||||||
|
msgid "Upvoted"
|
||||||
|
msgstr "Upvoted"
|
||||||
|
|
||||||
|
#. placeholder {0}: votes.items.length
|
||||||
|
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPublicProfile.tsx:829
|
||||||
|
msgid "Upvoted ({0}{1})"
|
||||||
|
msgstr "Upvoted ({0}{1})"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:309
|
||||||
|
#: src/pages/DumpEdit.tsx:221
|
||||||
|
msgid "URL"
|
||||||
|
msgstr "URL"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:164
|
||||||
|
msgid "URL is required."
|
||||||
|
msgstr "URL is required."
|
||||||
|
|
||||||
|
#: src/components/UserMenu.tsx:37
|
||||||
|
msgid "User menu"
|
||||||
|
msgstr "User menu"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:72
|
||||||
|
#: src/pages/UserRegister.tsx:125
|
||||||
|
msgid "Username"
|
||||||
|
msgstr "Username"
|
||||||
|
|
||||||
|
#: api/auth:
|
||||||
|
#~ msgid "Username already exists"
|
||||||
|
#~ msgstr "Username already exists"
|
||||||
|
|
||||||
|
#: api/auth:
|
||||||
|
#~ msgid "Username must be 1–32 characters and contain only letters, numbers, or underscores"
|
||||||
|
#~ msgstr "Username must be 1–32 characters and contain only letters, numbers, or underscores"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:174
|
||||||
|
msgid "Users"
|
||||||
|
msgstr "Users"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:878
|
||||||
|
#: src/pages/UserPublicProfile.tsx:948
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1076
|
||||||
|
msgid "View all →"
|
||||||
|
msgstr "View all →"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:418
|
||||||
|
msgid "View dump →"
|
||||||
|
msgstr "View dump →"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:356
|
||||||
|
#: src/pages/DumpEdit.tsx:250
|
||||||
|
msgid "Why are you dumping this?"
|
||||||
|
msgstr "Why are you dumping this?"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:329
|
||||||
|
msgid "Write a reply…"
|
||||||
|
msgstr "Write a reply…"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:341
|
||||||
|
msgid "Yesterday"
|
||||||
|
msgstr "Yesterday"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:329
|
||||||
|
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
|
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
|
|
||||||
|
#: src/pages/index/HotFeed.tsx:54
|
||||||
|
#: src/pages/index/JournalFeed.tsx:67
|
||||||
|
#: src/pages/index/NewFeed.tsx:54
|
||||||
|
#: src/pages/Search.tsx:242
|
||||||
|
msgid "You've reached the end."
|
||||||
|
msgstr "You've reached the end."
|
||||||
870
src/locales/fr.po
Normal file
870
src/locales/fr.po
Normal file
@@ -0,0 +1,870 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"POT-Creation-Date: 2026-04-01 16:55+0000\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"X-Generator: @lingui/cli\n"
|
||||||
|
"Language: fr\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:170
|
||||||
|
msgid "[deleted]"
|
||||||
|
msgstr "[supprimé]"
|
||||||
|
|
||||||
|
#. placeholder {0}: dump.commentCount
|
||||||
|
#: src/components/DumpCard.tsx:82
|
||||||
|
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||||
|
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: playlist.dumpCount
|
||||||
|
#: src/components/PlaylistCard.tsx:84
|
||||||
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
|
msgstr "{0, plural, one {# reco} other {# recos}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: VALIDATION.USERNAME_MIN
|
||||||
|
#. placeholder {1}: VALIDATION.USERNAME_MAX
|
||||||
|
#: src/pages/UserRegister.tsx:128
|
||||||
|
msgid "{0}–{1} characters: letters, numbers, or underscores"
|
||||||
|
msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:184
|
||||||
|
msgid "{days}d ago"
|
||||||
|
msgstr "il y a {days}j"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:182
|
||||||
|
msgid "{hrs}h ago"
|
||||||
|
msgstr "il y a {hrs}h"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:176
|
||||||
|
msgid "{label} ({count})"
|
||||||
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:180
|
||||||
|
msgid "{mins}m ago"
|
||||||
|
msgstr "il y a {mins}min"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:436
|
||||||
|
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
|
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:605
|
||||||
|
#: src/pages/UserPublicProfile.tsx:606
|
||||||
|
msgid "← Back"
|
||||||
|
msgstr "← Retour"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:216
|
||||||
|
#: src/pages/Dump.tsx:318
|
||||||
|
#: src/pages/DumpEdit.tsx:166
|
||||||
|
msgid "← Back to all dumps"
|
||||||
|
msgstr "← Retour à toutes les recos"
|
||||||
|
|
||||||
|
#: src/pages/UserDumps.tsx:61
|
||||||
|
#: src/pages/UserPlaylists.tsx:352
|
||||||
|
#: src/pages/UserUpvoted.tsx:130
|
||||||
|
msgid "← Back to profile"
|
||||||
|
msgstr "← Retour au profil"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:90
|
||||||
|
msgid "+ Invite someone"
|
||||||
|
msgstr "+ Inviter quelqu'un"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:63
|
||||||
|
msgid "+ New"
|
||||||
|
msgstr "+ Nouveau"
|
||||||
|
|
||||||
|
#: src/pages/UserDumps.tsx:82
|
||||||
|
#: src/pages/UserPublicProfile.tsx:922
|
||||||
|
msgid "+ New dump"
|
||||||
|
msgstr "+ Nouvelle reco"
|
||||||
|
|
||||||
|
#: src/components/PlaylistMembershipPanel.tsx:72
|
||||||
|
msgid "+ New playlist"
|
||||||
|
msgstr "+ Nouvelle collection"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:248
|
||||||
|
msgid "+ Playlist"
|
||||||
|
msgstr "+ Collection"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.followerUsername
|
||||||
|
#. placeholder {1}: d.playlistTitle
|
||||||
|
#: src/pages/Notifications.tsx:124
|
||||||
|
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.mentionerUsername
|
||||||
|
#: src/pages/Notifications.tsx:166
|
||||||
|
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||||
|
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.dumperUsername
|
||||||
|
#. placeholder {1}: d.dumpTitle
|
||||||
|
#: src/pages/Notifications.tsx:134
|
||||||
|
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0> a publié <1>{1}</1>"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.followerUsername
|
||||||
|
#: src/pages/Notifications.tsx:115
|
||||||
|
msgid "<0>{0}</0> started following you"
|
||||||
|
msgstr "<0>{0}</0> a commencé à vous suivre"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.voterUsername
|
||||||
|
#. placeholder {1}: d.dumpTitle
|
||||||
|
#: src/pages/Notifications.tsx:154
|
||||||
|
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
|
||||||
|
|
||||||
|
#. placeholder {0}: d.dumpTitle
|
||||||
|
#. placeholder {1}: d.playlistTitle
|
||||||
|
#: src/pages/Notifications.tsx:144
|
||||||
|
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:164
|
||||||
|
msgid "a comment"
|
||||||
|
msgstr "un commentaire"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:164
|
||||||
|
msgid "a post"
|
||||||
|
msgstr "une publication"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:802
|
||||||
|
msgid "Add a bio…"
|
||||||
|
msgstr "Ajouter une bio…"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:456
|
||||||
|
msgid "Add a comment…"
|
||||||
|
msgstr "Ajouter un commentaire…"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:718
|
||||||
|
msgid "Add email…"
|
||||||
|
msgstr "Ajouter un e-mail…"
|
||||||
|
|
||||||
|
#: src/components/AddToPlaylistModal.tsx:64
|
||||||
|
#: src/components/DumpCreateModal.tsx:262
|
||||||
|
msgid "Add to playlist"
|
||||||
|
msgstr "Ajouter à la collection"
|
||||||
|
|
||||||
|
#. placeholder {0}: dumps.length
|
||||||
|
#: src/pages/UserDumps.tsx:114
|
||||||
|
msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||||
|
msgstr "Toutes les {0, plural, one {# reco} other {# recos}} chargées."
|
||||||
|
|
||||||
|
#. placeholder {0}: votes.length
|
||||||
|
#: src/pages/UserUpvoted.tsx:184
|
||||||
|
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||||
|
msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} chargées."
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:160
|
||||||
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
|
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
||||||
|
|
||||||
|
#: src/contexts/WSProvider.tsx:168
|
||||||
|
#: src/contexts/WSProvider.tsx:360
|
||||||
|
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
|
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:268
|
||||||
|
#: src/components/CommentThread.tsx:353
|
||||||
|
#: src/components/CommentThread.tsx:483
|
||||||
|
#: src/components/ConfirmModal.tsx:32
|
||||||
|
#: src/components/DumpCreateModal.tsx:394
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:105
|
||||||
|
#: src/pages/DumpEdit.tsx:288
|
||||||
|
#: src/pages/PlaylistDetail.tsx:672
|
||||||
|
#: src/pages/UserPublicProfile.tsx:700
|
||||||
|
#: src/pages/UserPublicProfile.tsx:773
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr "Annuler"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:848
|
||||||
|
msgid "Cancel removal"
|
||||||
|
msgstr "Annuler la suppression"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:633
|
||||||
|
msgid "Change avatar"
|
||||||
|
msgstr "Changer l'avatar"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:94
|
||||||
|
msgid "Checking invite…"
|
||||||
|
msgstr "Vérification de l'invitation…"
|
||||||
|
|
||||||
|
#: src/components/Modal.tsx:45
|
||||||
|
msgid "Close"
|
||||||
|
msgstr "Fermer"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:81
|
||||||
|
msgid "Copied!"
|
||||||
|
msgstr "Copié !"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:81
|
||||||
|
msgid "Copy"
|
||||||
|
msgstr "Copier"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:108
|
||||||
|
#: src/components/CommentThread.tsx:147
|
||||||
|
#: src/components/CommentThread.tsx:425
|
||||||
|
msgid "Could not reach the server. Please try again."
|
||||||
|
msgstr "Impossible de contacter le serveur. Veuillez réessayer."
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:116
|
||||||
|
msgid "Create"
|
||||||
|
msgstr "Créer"
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:115
|
||||||
|
msgid "Create & Add"
|
||||||
|
msgstr "Créer et ajouter"
|
||||||
|
|
||||||
|
#. placeholder {0}: created.items.length
|
||||||
|
#. placeholder {1}: created.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPlaylists.tsx:386
|
||||||
|
msgid "Created ({0}{1})"
|
||||||
|
msgstr "Créées ({0}{1})"
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:113
|
||||||
|
msgid "Creating…"
|
||||||
|
msgstr "Création…"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:306
|
||||||
|
#: src/components/CommentThread.tsx:312
|
||||||
|
#: src/components/ConfirmModal.tsx:16
|
||||||
|
#: src/pages/PlaylistDetail.tsx:679
|
||||||
|
msgid "Delete"
|
||||||
|
msgstr "Supprimer"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:284
|
||||||
|
#: src/pages/DumpEdit.tsx:300
|
||||||
|
msgid "Delete dump"
|
||||||
|
msgstr "Supprimer la reco"
|
||||||
|
|
||||||
|
#: src/components/PlaylistCard.tsx:107
|
||||||
|
#: src/pages/PlaylistDetail.tsx:861
|
||||||
|
#: src/pages/UserPlaylists.tsx:443
|
||||||
|
msgid "Delete playlist"
|
||||||
|
msgstr "Supprimer la collection"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:311
|
||||||
|
msgid "Delete this comment?"
|
||||||
|
msgstr "Supprimer ce commentaire ?"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:299
|
||||||
|
msgid "Delete this dump? This cannot be undone."
|
||||||
|
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:860
|
||||||
|
#: src/pages/UserPlaylists.tsx:442
|
||||||
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
|
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:76
|
||||||
|
#: src/pages/PlaylistDetail.tsx:710
|
||||||
|
msgid "Description (optional)"
|
||||||
|
msgstr "Description (facultatif)"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:439
|
||||||
|
msgid "Done"
|
||||||
|
msgstr "Terminé"
|
||||||
|
|
||||||
|
#: src/components/FileDropZone.tsx:32
|
||||||
|
msgid "Drop a file here"
|
||||||
|
msgstr "Déposez un fichier ici"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:242
|
||||||
|
msgid "Drop a replacement here"
|
||||||
|
msgstr "Déposez un fichier de remplacement ici"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:405
|
||||||
|
msgid "Dump it"
|
||||||
|
msgstr "Recommander"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:416
|
||||||
|
msgid "Dumped!"
|
||||||
|
msgstr "Recommandé !"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:172
|
||||||
|
#: src/pages/UserDumps.tsx:75
|
||||||
|
msgid "Dumps"
|
||||||
|
msgstr "Recos"
|
||||||
|
|
||||||
|
#. placeholder {0}: dumps.items.length
|
||||||
|
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPublicProfile.tsx:817
|
||||||
|
msgid "Dumps ({0}{1})"
|
||||||
|
msgstr "Recos ({0}{1})"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:341
|
||||||
|
msgid "Earlier"
|
||||||
|
msgstr "Plus tôt"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:297
|
||||||
|
#: src/pages/Dump.tsx:315
|
||||||
|
#: src/pages/PlaylistDetail.tsx:698
|
||||||
|
msgid "Edit"
|
||||||
|
msgstr "Modifier"
|
||||||
|
|
||||||
|
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||||
|
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||||
|
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
||||||
|
#: src/components/CommentThread.tsx:231
|
||||||
|
#: src/pages/Dump.tsx:276
|
||||||
|
#: src/pages/PlaylistDetail.tsx:768
|
||||||
|
msgid "edited {0}"
|
||||||
|
msgstr "modifié {0}"
|
||||||
|
|
||||||
|
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||||
|
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||||
|
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
||||||
|
#: src/components/CommentThread.tsx:229
|
||||||
|
#: src/pages/Dump.tsx:274
|
||||||
|
#: src/pages/PlaylistDetail.tsx:765
|
||||||
|
msgid "Edited {0}"
|
||||||
|
msgstr "Modifié le {0}"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:180
|
||||||
|
msgid "Editing"
|
||||||
|
msgstr "Modification"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:135
|
||||||
|
msgid "Email address"
|
||||||
|
msgstr "Adresse e-mail"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:206
|
||||||
|
msgid "Enter a query to search."
|
||||||
|
msgstr "Saisissez une recherche."
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:59
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:97
|
||||||
|
msgid "Failed to create playlist"
|
||||||
|
msgstr "Impossible de créer la collection"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:62
|
||||||
|
#: src/pages/UserPublicProfile.tsx:65
|
||||||
|
#: src/pages/UserPublicProfile.tsx:92
|
||||||
|
msgid "Failed to generate invite"
|
||||||
|
msgstr "Impossible de générer une invitation"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:77
|
||||||
|
#: src/pages/index/HotFeed.tsx:30
|
||||||
|
#: src/pages/index/JournalFeed.tsx:42
|
||||||
|
#: src/pages/index/NewFeed.tsx:30
|
||||||
|
#: src/pages/Notifications.tsx:321
|
||||||
|
msgid "Failed to load"
|
||||||
|
msgstr "Chargement échoué"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:300
|
||||||
|
msgid "Failed to post"
|
||||||
|
msgstr "Publication échouée"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:462
|
||||||
|
msgid "Failed to post comment"
|
||||||
|
msgstr "Impossible de publier le commentaire"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:334
|
||||||
|
msgid "Failed to post reply"
|
||||||
|
msgstr "Impossible de publier la réponse"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:776
|
||||||
|
#: src/pages/UserPublicProfile.tsx:546
|
||||||
|
#: src/pages/UserPublicProfile.tsx:581
|
||||||
|
#: src/pages/UserPublicProfile.tsx:704
|
||||||
|
#: src/pages/UserPublicProfile.tsx:776
|
||||||
|
msgid "Failed to save"
|
||||||
|
msgstr "Enregistrement échoué"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:249
|
||||||
|
msgid "Failed to save edit"
|
||||||
|
msgstr "Impossible d'enregistrer la modification"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:726
|
||||||
|
msgid "Failed to update avatar"
|
||||||
|
msgstr "Impossible de mettre à jour l'avatar"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:333
|
||||||
|
msgid "Fetching preview…"
|
||||||
|
msgstr "Récupération de l'aperçu…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:403
|
||||||
|
msgid "Fetching…"
|
||||||
|
msgstr "Récupération…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:293
|
||||||
|
#: src/components/FileDropZone.tsx:31
|
||||||
|
msgid "File"
|
||||||
|
msgstr "Fichier"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:187
|
||||||
|
msgid "File too large (max 50 MB)."
|
||||||
|
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:37
|
||||||
|
#: src/components/FollowButton.tsx:64
|
||||||
|
msgid "Follow"
|
||||||
|
msgstr "Suivre"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:35
|
||||||
|
msgid "Follow {targetUsername}"
|
||||||
|
msgstr "Suivre {targetUsername}"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:62
|
||||||
|
msgid "Follow playlist"
|
||||||
|
msgstr "Suivre la collection"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:359
|
||||||
|
msgid "Follow some public playlists to see their dumps here."
|
||||||
|
msgstr "Suivez des collections publiques pour voir leurs recos ici."
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:345
|
||||||
|
msgid "Follow some users to see their dumps here."
|
||||||
|
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||||
|
|
||||||
|
#: src/components/FeedTabBar.tsx:47
|
||||||
|
msgid "Followed"
|
||||||
|
msgstr "Suivi"
|
||||||
|
|
||||||
|
#. placeholder {0}: followed.items.length
|
||||||
|
#. placeholder {1}: followed.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPlaylists.tsx:416
|
||||||
|
msgid "Followed ({0}{1})"
|
||||||
|
msgstr "Suivies ({0}{1})"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:37
|
||||||
|
#: src/components/FollowButton.tsx:64
|
||||||
|
msgid "Following"
|
||||||
|
msgstr "Abonné"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:325
|
||||||
|
msgid "From people"
|
||||||
|
msgstr "De personnes"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:332
|
||||||
|
msgid "From playlists"
|
||||||
|
msgstr "De collections"
|
||||||
|
|
||||||
|
#: src/components/FeedTabBar.tsx:25
|
||||||
|
msgid "Hot"
|
||||||
|
msgstr "Tendances"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:104
|
||||||
|
msgid "Invalid invite"
|
||||||
|
msgstr "Invitation invalide"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:651
|
||||||
|
msgid "invited by"
|
||||||
|
msgstr "invité par"
|
||||||
|
|
||||||
|
#: src/components/FeedTabBar.tsx:39
|
||||||
|
msgid "Journal"
|
||||||
|
msgstr "Journal"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:178
|
||||||
|
msgid "just now"
|
||||||
|
msgstr "à l'instant"
|
||||||
|
|
||||||
|
#: src/contexts/WSProvider.tsx:359
|
||||||
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:79
|
||||||
|
msgid "Live updates unavailable."
|
||||||
|
msgstr "Mises à jour en direct indisponibles."
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:386
|
||||||
|
msgid "Load more"
|
||||||
|
msgstr "Charger plus"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:193
|
||||||
|
#: src/pages/DumpEdit.tsx:143
|
||||||
|
msgid "Loading dump…"
|
||||||
|
msgstr "Chargement de la reco…"
|
||||||
|
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:103
|
||||||
|
#: src/pages/index/HotFeed.tsx:52
|
||||||
|
#: src/pages/index/JournalFeed.tsx:65
|
||||||
|
#: src/pages/index/NewFeed.tsx:52
|
||||||
|
#: src/pages/Search.tsx:239
|
||||||
|
#: src/pages/UserDumps.tsx:111
|
||||||
|
#: src/pages/UserPlaylists.tsx:409
|
||||||
|
#: src/pages/UserPlaylists.tsx:436
|
||||||
|
#: src/pages/UserUpvoted.tsx:180
|
||||||
|
msgid "Loading more…"
|
||||||
|
msgstr "Chargement…"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:590
|
||||||
|
msgid "Loading playlist…"
|
||||||
|
msgstr "Chargement de la collection…"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:590
|
||||||
|
msgid "Loading profile…"
|
||||||
|
msgstr "Chargement du profil…"
|
||||||
|
|
||||||
|
#: src/components/PlaylistMembershipPanel.tsx:26
|
||||||
|
#: src/components/TextEditor.tsx:273
|
||||||
|
#: src/pages/index/FollowedFeed.tsx:74
|
||||||
|
#: src/pages/index/HotFeed.tsx:29
|
||||||
|
#: src/pages/index/JournalFeed.tsx:41
|
||||||
|
#: src/pages/index/NewFeed.tsx:29
|
||||||
|
#: src/pages/Notifications.tsx:318
|
||||||
|
#: src/pages/Notifications.tsx:386
|
||||||
|
#: src/pages/UserDumps.tsx:50
|
||||||
|
#: src/pages/UserPlaylists.tsx:341
|
||||||
|
#: src/pages/UserUpvoted.tsx:119
|
||||||
|
msgid "Loading…"
|
||||||
|
msgstr "Chargement…"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:70
|
||||||
|
#: src/pages/UserLogin.tsx:62
|
||||||
|
#: src/pages/UserLogin.tsx:91
|
||||||
|
msgid "Log in"
|
||||||
|
msgstr "Se connecter"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:610
|
||||||
|
#: src/pages/UserPublicProfile.tsx:738
|
||||||
|
msgid "Log out"
|
||||||
|
msgstr "Se déconnecter"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:90
|
||||||
|
msgid "Logging in…"
|
||||||
|
msgstr "Connexion…"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:65
|
||||||
|
msgid "Login failed"
|
||||||
|
msgstr "Connexion échouée"
|
||||||
|
|
||||||
|
#: src/components/FileDropZone.tsx:141
|
||||||
|
msgid "Max 50 MB"
|
||||||
|
msgstr "Max 50 Mo"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:312
|
||||||
|
msgid "new"
|
||||||
|
msgstr "nouveau"
|
||||||
|
|
||||||
|
#: src/components/FeedTabBar.tsx:32
|
||||||
|
msgid "New"
|
||||||
|
msgstr "Nouveau"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:262
|
||||||
|
msgid "New dump"
|
||||||
|
msgstr "Nouvelle reco"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:783
|
||||||
|
msgid "No dumps in this playlist yet."
|
||||||
|
msgstr "Aucune reco dans cette collection pour l'instant."
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:220
|
||||||
|
msgid "No dumps match \"{q}\"."
|
||||||
|
msgstr "Aucune reco ne correspond à « {q} »."
|
||||||
|
|
||||||
|
#: src/pages/index/HotFeed.tsx:32
|
||||||
|
#: src/pages/index/JournalFeed.tsx:44
|
||||||
|
#: src/pages/index/NewFeed.tsx:32
|
||||||
|
msgid "No dumps yet. Be the first!"
|
||||||
|
msgstr "Pas encore de recos. Soyez le premier !"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:274
|
||||||
|
msgid "No emoji found."
|
||||||
|
msgstr "Aucun emoji trouvé."
|
||||||
|
|
||||||
|
#: src/pages/UserPlaylists.tsx:424
|
||||||
|
msgid "No followed playlists yet."
|
||||||
|
msgstr "Pas encore de collections suivies."
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:273
|
||||||
|
msgid "No playlists match \"{q}\"."
|
||||||
|
msgstr "Aucune collection ne correspond à « {q} »."
|
||||||
|
|
||||||
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
|
#: src/pages/UserPlaylists.tsx:392
|
||||||
|
#: src/pages/UserPublicProfile.tsx:865
|
||||||
|
msgid "No playlists yet."
|
||||||
|
msgstr "Pas encore de collections."
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:249
|
||||||
|
msgid "No users match \"{q}\"."
|
||||||
|
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:327
|
||||||
|
#: src/pages/UserDumps.tsx:92
|
||||||
|
#: src/pages/UserPublicProfile.tsx:930
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1049
|
||||||
|
#: src/pages/UserUpvoted.tsx:151
|
||||||
|
msgid "Nothing here yet."
|
||||||
|
msgstr "Rien ici pour l'instant."
|
||||||
|
|
||||||
|
#: src/components/NotificationBell.tsx:42
|
||||||
|
#: src/pages/Notifications.tsx:308
|
||||||
|
msgid "Notifications"
|
||||||
|
msgstr "Notifications"
|
||||||
|
|
||||||
|
#: src/components/NotificationBell.tsx:41
|
||||||
|
msgid "Notifications ({unreadNotificationCount} unread)"
|
||||||
|
msgstr "Notifications ({unreadNotificationCount} non lues)"
|
||||||
|
|
||||||
|
#: src/components/SearchBar.tsx:71
|
||||||
|
msgid "Open search"
|
||||||
|
msgstr "Ouvrir la recherche"
|
||||||
|
|
||||||
|
#: src/components/FileDropZone.tsx:139
|
||||||
|
msgid "or <0>browse files</0>"
|
||||||
|
msgstr "ou <0>parcourir les fichiers</0>"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:80
|
||||||
|
msgid "Password"
|
||||||
|
msgstr "Mot de passe"
|
||||||
|
|
||||||
|
#. placeholder {0}: VALIDATION.PASSWORD_MIN
|
||||||
|
#: src/pages/UserRegister.tsx:142
|
||||||
|
msgid "Password (min. {0} characters)"
|
||||||
|
msgstr "Mot de passe (min. {0} caractères)"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:46
|
||||||
|
#: src/components/UserMenu.tsx:62
|
||||||
|
#: src/pages/Search.tsx:175
|
||||||
|
#: src/pages/UserPlaylists.tsx:366
|
||||||
|
msgid "Playlists"
|
||||||
|
msgstr "Collections"
|
||||||
|
|
||||||
|
#. placeholder {0}: playlists.items.length
|
||||||
|
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPublicProfile.tsx:845
|
||||||
|
msgid "Playlists ({0}{1})"
|
||||||
|
msgstr "Collections ({0}{1})"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:180
|
||||||
|
msgid "Please select a file."
|
||||||
|
msgstr "Veuillez sélectionner un fichier."
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:472
|
||||||
|
msgid "Post comment"
|
||||||
|
msgstr "Publier le commentaire"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:342
|
||||||
|
msgid "Post reply"
|
||||||
|
msgstr "Publier la réponse"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:342
|
||||||
|
#: src/components/CommentThread.tsx:472
|
||||||
|
msgid "Posting…"
|
||||||
|
msgstr "Publication…"
|
||||||
|
|
||||||
|
#: src/components/DumpCard.tsx:91
|
||||||
|
#: src/components/PlaylistCard.tsx:71
|
||||||
|
#: src/components/PlaylistMembershipPanel.tsx:47
|
||||||
|
#: src/pages/Dump.tsx:282
|
||||||
|
#: src/pages/PlaylistDetail.tsx:748
|
||||||
|
msgid "private"
|
||||||
|
msgstr "privé"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:383
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:94
|
||||||
|
#: src/pages/DumpEdit.tsx:274
|
||||||
|
#: src/pages/PlaylistDetail.tsx:737
|
||||||
|
msgid "Private"
|
||||||
|
msgstr "Privé"
|
||||||
|
|
||||||
|
#: src/components/PlaylistCard.tsx:71
|
||||||
|
#: src/pages/PlaylistDetail.tsx:748
|
||||||
|
msgid "public"
|
||||||
|
msgstr "public"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:375
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:87
|
||||||
|
#: src/pages/DumpEdit.tsx:267
|
||||||
|
#: src/pages/PlaylistDetail.tsx:730
|
||||||
|
msgid "Public"
|
||||||
|
msgstr "Public"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:206
|
||||||
|
msgid "Refresh metadata"
|
||||||
|
msgstr "Actualiser les métadonnées"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:206
|
||||||
|
msgid "Refreshing…"
|
||||||
|
msgstr "Actualisation…"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:115
|
||||||
|
#: src/pages/UserRegister.tsx:155
|
||||||
|
msgid "Register"
|
||||||
|
msgstr "S'inscrire"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:154
|
||||||
|
msgid "Registering…"
|
||||||
|
msgstr "Inscription…"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:118
|
||||||
|
msgid "Registration failed"
|
||||||
|
msgstr "Inscription échouée"
|
||||||
|
|
||||||
|
#: src/components/FileDropZone.tsx:115
|
||||||
|
msgid "Remove file"
|
||||||
|
msgstr "Supprimer le fichier"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:838
|
||||||
|
msgid "Remove from playlist"
|
||||||
|
msgstr "Retirer de la collection"
|
||||||
|
|
||||||
|
#: src/pages/DumpEdit.tsx:241
|
||||||
|
msgid "Replace file"
|
||||||
|
msgstr "Remplacer le fichier"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:284
|
||||||
|
msgid "Reply"
|
||||||
|
msgstr "Répondre"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:209
|
||||||
|
#: src/pages/DumpEdit.tsx:159
|
||||||
|
msgid "Retry"
|
||||||
|
msgstr "Réessayer"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:257
|
||||||
|
#: src/pages/DumpEdit.tsx:291
|
||||||
|
#: src/pages/PlaylistDetail.tsx:665
|
||||||
|
#: src/pages/UserPublicProfile.tsx:692
|
||||||
|
#: src/pages/UserPublicProfile.tsx:765
|
||||||
|
msgid "Save"
|
||||||
|
msgstr "Enregistrer"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:257
|
||||||
|
#: src/pages/PlaylistDetail.tsx:665
|
||||||
|
#: src/pages/UserPublicProfile.tsx:692
|
||||||
|
#: src/pages/UserPublicProfile.tsx:765
|
||||||
|
msgid "Saving…"
|
||||||
|
msgstr "Enregistrement…"
|
||||||
|
|
||||||
|
#: src/components/SearchBar.tsx:65
|
||||||
|
msgid "Search"
|
||||||
|
msgstr "Rechercher"
|
||||||
|
|
||||||
|
#: src/components/SearchBar.tsx:61
|
||||||
|
msgid "Search dumps, users, playlists…"
|
||||||
|
msgstr "Rechercher des recos, utilisateurs, collections…"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:214
|
||||||
|
msgid "Search failed"
|
||||||
|
msgstr "Recherche échouée"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:210
|
||||||
|
msgid "Searching…"
|
||||||
|
msgstr "Recherche…"
|
||||||
|
|
||||||
|
#: src/components/AppHeader.tsx:61
|
||||||
|
msgid "Server unreachable"
|
||||||
|
msgstr "Serveur inaccessible"
|
||||||
|
|
||||||
|
#: src/components/PageError.tsx:13
|
||||||
|
msgid "Something went wrong"
|
||||||
|
msgstr "Une erreur est survenue"
|
||||||
|
|
||||||
|
#: src/components/SearchBar.tsx:71
|
||||||
|
msgid "Submit search"
|
||||||
|
msgstr "Lancer la recherche"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:755
|
||||||
|
msgid "Tell people about yourself…"
|
||||||
|
msgstr "Parlez de vous…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:363
|
||||||
|
#: src/pages/DumpEdit.tsx:256
|
||||||
|
msgid "Tell the community what makes this worth their time..."
|
||||||
|
msgstr "Dites à la communauté pourquoi ça vaut le coup…"
|
||||||
|
|
||||||
|
#: src/pages/UserRegister.tsx:105
|
||||||
|
msgid "This invite link is missing, expired, or already used."
|
||||||
|
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:96
|
||||||
|
msgid "This is a mirage."
|
||||||
|
msgstr "C'est un mirage."
|
||||||
|
|
||||||
|
#: src/components/PlaylistCreateForm.tsx:69
|
||||||
|
msgid "Title"
|
||||||
|
msgstr "Titre"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:341
|
||||||
|
msgid "Today"
|
||||||
|
msgstr "Aujourd'hui"
|
||||||
|
|
||||||
|
#: src/pages/PlaylistDetail.tsx:850
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr "Annuler"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:34
|
||||||
|
msgid "Unfollow {targetUsername}"
|
||||||
|
msgstr "Ne plus suivre {targetUsername}"
|
||||||
|
|
||||||
|
#: src/components/FollowButton.tsx:62
|
||||||
|
msgid "Unfollow playlist"
|
||||||
|
msgstr "Ne plus suivre la collection"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:515
|
||||||
|
msgid "Upload failed"
|
||||||
|
msgstr "Envoi échoué"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:404
|
||||||
|
msgid "Uploading…"
|
||||||
|
msgstr "Envoi…"
|
||||||
|
|
||||||
|
#: src/pages/UserUpvoted.tsx:147
|
||||||
|
msgid "Upvoted"
|
||||||
|
msgstr "Voté"
|
||||||
|
|
||||||
|
#. placeholder {0}: votes.items.length
|
||||||
|
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||||
|
#: src/pages/UserPublicProfile.tsx:829
|
||||||
|
msgid "Upvoted ({0}{1})"
|
||||||
|
msgstr "Votés ({0}{1})"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:309
|
||||||
|
#: src/pages/DumpEdit.tsx:221
|
||||||
|
msgid "URL"
|
||||||
|
msgstr "URL"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:164
|
||||||
|
msgid "URL is required."
|
||||||
|
msgstr "L'URL est obligatoire."
|
||||||
|
|
||||||
|
#: src/components/UserMenu.tsx:37
|
||||||
|
msgid "User menu"
|
||||||
|
msgstr "Menu utilisateur"
|
||||||
|
|
||||||
|
#: src/pages/UserLogin.tsx:72
|
||||||
|
#: src/pages/UserRegister.tsx:125
|
||||||
|
msgid "Username"
|
||||||
|
msgstr "Nom d'utilisateur"
|
||||||
|
|
||||||
|
#: src/pages/Search.tsx:174
|
||||||
|
msgid "Users"
|
||||||
|
msgstr "Utilisateurs"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:878
|
||||||
|
#: src/pages/UserPublicProfile.tsx:948
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1076
|
||||||
|
msgid "View all →"
|
||||||
|
msgstr "Tout voir →"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:418
|
||||||
|
msgid "View dump →"
|
||||||
|
msgstr "Voir la reco →"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:356
|
||||||
|
#: src/pages/DumpEdit.tsx:250
|
||||||
|
msgid "Why are you dumping this?"
|
||||||
|
msgstr "Pourquoi recommandez-vous ça ?"
|
||||||
|
|
||||||
|
#: src/components/CommentThread.tsx:329
|
||||||
|
msgid "Write a reply…"
|
||||||
|
msgstr "Écrire une réponse…"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:341
|
||||||
|
msgid "Yesterday"
|
||||||
|
msgstr "Hier"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:329
|
||||||
|
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
|
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
||||||
|
|
||||||
|
#: src/pages/index/HotFeed.tsx:54
|
||||||
|
#: src/pages/index/JournalFeed.tsx:67
|
||||||
|
#: src/pages/index/NewFeed.tsx:54
|
||||||
|
#: src/pages/Search.tsx:242
|
||||||
|
msgid "You've reached the end."
|
||||||
|
msgstr "Vous avez tout lu, tout vu, tout bu."
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { I18nProvider } from "@lingui/react";
|
||||||
|
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
import { i18n, loadCatalog } from "./i18n.ts";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
await loadCatalog();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<I18nProvider i18n={i18n}>
|
||||||
<App />
|
<App />
|
||||||
|
</I18nProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { dumpUrl } from "../utils/urls.ts";
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ export function Dump() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [selectedDump, preloaded]);
|
}, [selectedDump, preloaded, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastDumpEvent) return;
|
if (!lastDumpEvent) return;
|
||||||
@@ -143,16 +145,14 @@ export function Dump() {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
el.classList.add("comment-node--highlight");
|
el.classList.add("comment-node--highlight");
|
||||||
const t = setTimeout(
|
const timer = setTimeout(
|
||||||
() => el.classList.remove("comment-node--highlight"),
|
() => el.classList.remove("comment-node--highlight"),
|
||||||
2000,
|
2000,
|
||||||
);
|
);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(timer);
|
||||||
}, [comments, location.hash]);
|
}, [comments, location.hash]);
|
||||||
|
|
||||||
// React to WS comment events
|
// React to WS comment events
|
||||||
// Note: selectedDump may be a slug, but lastCommentEvent.dumpId is always a UUID.
|
|
||||||
// Compare against the loaded dump's actual ID.
|
|
||||||
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
|
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -190,7 +190,7 @@ export function Dump() {
|
|||||||
if (dumpState.status === "loading") {
|
if (dumpState.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<p className="page-loading">Loading dump…</p>
|
<p className="page-loading"><Trans>Loading dump…</Trans></p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,14 +206,14 @@ export function Dump() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => globalThis.location.reload()}
|
onClick={() => globalThis.location.reload()}
|
||||||
>
|
>
|
||||||
Retry
|
<Trans>Retry</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-border"
|
className="btn-border"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
>
|
>
|
||||||
← Back to all dumps
|
<Trans>← Back to all dumps</Trans>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,7 @@ export function Dump() {
|
|||||||
className="btn-add-playlist"
|
className="btn-add-playlist"
|
||||||
onClick={() => setPlaylistModalOpen(true)}
|
onClick={() => setPlaylistModalOpen(true)}
|
||||||
>
|
>
|
||||||
+ Playlist
|
<Trans>+ Playlist</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="dump-op">
|
<div className="dump-op">
|
||||||
@@ -271,14 +271,16 @@ export function Dump() {
|
|||||||
</time>
|
</time>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{dump.updatedAt && (
|
{dump.updatedAt && (
|
||||||
<Tooltip text={`Edited ${dump.updatedAt.toLocaleString()}`}>
|
<Tooltip text={t`Edited ${dump.updatedAt.toLocaleString()}`}>
|
||||||
<span className="dump-edited-label">
|
<span className="dump-edited-label">
|
||||||
edited {relativeTime(dump.updatedAt)}
|
<Trans>edited {relativeTime(dump.updatedAt)}</Trans>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{dump.isPrivate && (
|
{dump.isPrivate && (
|
||||||
<span className="dump-card-private-badge">private</span>
|
<span className="dump-card-private-badge">
|
||||||
|
<Trans>private</Trans>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +293,7 @@ export function Dump() {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="dump-rich-content">
|
<div className="dump-rich-content">
|
||||||
{dump.kind === "file"
|
{dump.kind === "file"
|
||||||
? <FilePreview dump={dump} />
|
? <FilePreview dump={dump} global />
|
||||||
: dump.richContent
|
: dump.richContent
|
||||||
? <RichContentCard richContent={dump.richContent} />
|
? <RichContentCard richContent={dump.richContent} />
|
||||||
: (
|
: (
|
||||||
@@ -308,8 +310,12 @@ export function Dump() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="dump-actions">
|
<div className="dump-actions">
|
||||||
{canEdit && <Link to={`${dumpUrl(dump)}/edit`}>Edit</Link>}
|
{canEdit && (
|
||||||
<Link to="/">← Back to all dumps</Link>
|
<Link to={`${dumpUrl(dump)}/edit`}>
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link to="/"><Trans>← Back to all dumps</Trans></Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
|
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
|
||||||
@@ -60,7 +62,7 @@ export function DumpEdit() {
|
|||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [selectedDump]);
|
}, [selectedDump, token]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (state.status !== "loaded") return;
|
if (state.status !== "loaded") return;
|
||||||
@@ -138,7 +140,7 @@ export function DumpEdit() {
|
|||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<p className="page-loading">Loading dump…</p>
|
<p className="page-loading"><Trans>Loading dump…</Trans></p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,14 +156,14 @@ export function DumpEdit() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => globalThis.location.reload()}
|
onClick={() => globalThis.location.reload()}
|
||||||
>
|
>
|
||||||
Retry
|
<Trans>Retry</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-border"
|
className="btn-border"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
>
|
>
|
||||||
← Back to all dumps
|
<Trans>← Back to all dumps</Trans>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -175,7 +177,7 @@ export function DumpEdit() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="form-page form-page--two-col">
|
<div className="form-page form-page--two-col">
|
||||||
<div className="form-page-header">
|
<div className="form-page-header">
|
||||||
<p className="form-page-eyebrow">Editing</p>
|
<p className="form-page-eyebrow"><Trans>Editing</Trans></p>
|
||||||
<h1 className="form-page-title">{dump.title}</h1>
|
<h1 className="form-page-title">{dump.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -201,7 +203,7 @@ export function DumpEdit() {
|
|||||||
onClick={handleRefreshMetadata}
|
onClick={handleRefreshMetadata}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
>
|
>
|
||||||
{refreshing ? "Refreshing…" : "Refresh metadata"}
|
{refreshing ? <Trans>Refreshing…</Trans> : <Trans>Refresh metadata</Trans>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +218,7 @@ export function DumpEdit() {
|
|||||||
{dump.kind === "url"
|
{dump.kind === "url"
|
||||||
? (
|
? (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="url">URL</label>
|
<label htmlFor="url"><Trans>URL</Trans></label>
|
||||||
<input
|
<input
|
||||||
id="url"
|
id="url"
|
||||||
type="url"
|
type="url"
|
||||||
@@ -236,20 +238,22 @@ export function DumpEdit() {
|
|||||||
<FileDropZone
|
<FileDropZone
|
||||||
file={newFile}
|
file={newFile}
|
||||||
onChange={setNewFile}
|
onChange={setNewFile}
|
||||||
label="Replace file"
|
label={t`Replace file`}
|
||||||
hint="Drop a replacement here"
|
hint={t`Drop a replacement here`}
|
||||||
showLimit={false}
|
showLimit={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="comment">Why are you dumping this?</label>
|
<label htmlFor="comment">
|
||||||
|
<Trans>Why are you dumping this?</Trans>
|
||||||
|
</label>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
id="comment"
|
id="comment"
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={setComment}
|
onChange={setComment}
|
||||||
placeholder="Tell the community what makes this worth their time..."
|
placeholder={t`Tell the community what makes this worth their time...`}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,14 +264,14 @@ export function DumpEdit() {
|
|||||||
className={!isPrivate ? "active" : ""}
|
className={!isPrivate ? "active" : ""}
|
||||||
onClick={() => setIsPrivate(false)}
|
onClick={() => setIsPrivate(false)}
|
||||||
>
|
>
|
||||||
Public
|
<Trans>Public</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={isPrivate ? "active" : ""}
|
className={isPrivate ? "active" : ""}
|
||||||
onClick={() => setIsPrivate(true)}
|
onClick={() => setIsPrivate(true)}
|
||||||
>
|
>
|
||||||
Private
|
<Trans>Private</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,21 +281,23 @@ export function DumpEdit() {
|
|||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
className="btn-danger"
|
className="btn-danger"
|
||||||
>
|
>
|
||||||
Delete dump
|
<Trans>Delete dump</Trans>
|
||||||
</button>
|
</button>
|
||||||
<div className="form-actions-right">
|
<div className="form-actions-right">
|
||||||
<Link to={dumpUrl(dump)} className="form-cancel">
|
<Link to={dumpUrl(dump)} className="form-cancel">
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
<button type="submit" className="btn-primary">Save</button>
|
<button type="submit" className="btn-primary">
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
message="Delete this dump? This cannot be undone."
|
message={t`Delete this dump? This cannot be undone.`}
|
||||||
confirmLabel="Delete dump"
|
confirmLabel={t`Delete dump`}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => setConfirmDelete(false)}
|
onCancel={() => setConfirmDelete(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,11 +11,8 @@ import { useLocation } from "react-router";
|
|||||||
import { AppHeader } from "../components/AppHeader.tsx";
|
import { AppHeader } from "../components/AppHeader.tsx";
|
||||||
import { SearchBar } from "../components/SearchBar.tsx";
|
import { SearchBar } from "../components/SearchBar.tsx";
|
||||||
import { PresenceRow } from "../components/PresenceRow.tsx";
|
import { PresenceRow } from "../components/PresenceRow.tsx";
|
||||||
import {
|
import { FeedTabBar } from "../components/FeedTabBar.tsx";
|
||||||
type FeedTab,
|
import { type FeedTab, VALID_TABS } from "../config/feedTabs.ts";
|
||||||
FeedTabBar,
|
|
||||||
VALID_TABS,
|
|
||||||
} from "../components/FeedTabBar.tsx";
|
|
||||||
|
|
||||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
|
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
@@ -110,62 +112,62 @@ function notificationContent(n: Notification): React.ReactNode {
|
|||||||
case "user_followed": {
|
case "user_followed": {
|
||||||
const d = data as UserFollowedData;
|
const d = data as UserFollowedData;
|
||||||
return (
|
return (
|
||||||
<>
|
<Trans>
|
||||||
<strong>{d.followerUsername}</strong>
|
<strong>{d.followerUsername}</strong>
|
||||||
{" started following you"}
|
{" started following you"}
|
||||||
</>
|
</Trans>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "playlist_followed": {
|
case "playlist_followed": {
|
||||||
const d = data as PlaylistFollowedData;
|
const d = data as PlaylistFollowedData;
|
||||||
return (
|
return (
|
||||||
<>
|
<Trans>
|
||||||
<strong>{d.followerUsername}</strong>
|
<strong>{d.followerUsername}</strong>
|
||||||
{" followed your playlist "}
|
{" followed your playlist "}
|
||||||
<strong>{d.playlistTitle}</strong>
|
<strong>{d.playlistTitle}</strong>
|
||||||
</>
|
</Trans>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "user_dump_posted": {
|
case "user_dump_posted": {
|
||||||
const d = data as UserDumpPostedData;
|
const d = data as UserDumpPostedData;
|
||||||
return (
|
return (
|
||||||
<>
|
<Trans>
|
||||||
<strong>{d.dumperUsername}</strong>
|
<strong>{d.dumperUsername}</strong>
|
||||||
{" posted "}
|
{" posted "}
|
||||||
<strong>{d.dumpTitle}</strong>
|
<strong>{d.dumpTitle}</strong>
|
||||||
</>
|
</Trans>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "playlist_dump_added": {
|
case "playlist_dump_added": {
|
||||||
const d = data as PlaylistDumpAddedData;
|
const d = data as PlaylistDumpAddedData;
|
||||||
return (
|
return (
|
||||||
<>
|
<Trans>
|
||||||
<strong>{d.dumpTitle}</strong>
|
<strong>{d.dumpTitle}</strong>
|
||||||
{" was added to "}
|
{" was added to "}
|
||||||
<strong>{d.playlistTitle}</strong>
|
<strong>{d.playlistTitle}</strong>
|
||||||
</>
|
</Trans>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "dump_upvoted": {
|
case "dump_upvoted": {
|
||||||
const d = data as DumpUpvotedData;
|
const d = data as DumpUpvotedData;
|
||||||
return (
|
return (
|
||||||
<>
|
<Trans>
|
||||||
<strong>{d.voterUsername}</strong>
|
<strong>{d.voterUsername}</strong>
|
||||||
{" upvoted "}
|
{" upvoted "}
|
||||||
<strong>{d.dumpTitle}</strong>
|
<strong>{d.dumpTitle}</strong>
|
||||||
</>
|
</Trans>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "user_mentioned": {
|
case "user_mentioned": {
|
||||||
const d = data as UserMentionedData;
|
const d = data as UserMentionedData;
|
||||||
const where = d.contextTitle ||
|
const where = d.contextTitle ||
|
||||||
(d.contextType === "comment" ? "a comment" : "a post");
|
(d.contextType === "comment" ? t`a comment` : t`a post`);
|
||||||
return (
|
return (
|
||||||
<>
|
<Trans>
|
||||||
<strong>{d.mentionerUsername}</strong>
|
<strong>{d.mentionerUsername}</strong>
|
||||||
{" mentioned you in "}
|
{" mentioned you in "}
|
||||||
<strong>{where}</strong>
|
<strong>{where}</strong>
|
||||||
</>
|
</Trans>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,13 +175,13 @@ function notificationContent(n: Notification): React.ReactNode {
|
|||||||
|
|
||||||
function timeAgo(date: Date): string {
|
function timeAgo(date: Date): string {
|
||||||
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
|
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
if (secs < 60) return "just now";
|
if (secs < 60) return t`just now`;
|
||||||
const mins = Math.floor(secs / 60);
|
const mins = Math.floor(secs / 60);
|
||||||
if (mins < 60) return `${mins}m ago`;
|
if (mins < 60) return t`${mins}m ago`;
|
||||||
const hrs = Math.floor(mins / 60);
|
const hrs = Math.floor(mins / 60);
|
||||||
if (hrs < 24) return `${hrs}h ago`;
|
if (hrs < 24) return t`${hrs}h ago`;
|
||||||
const days = Math.floor(hrs / 24);
|
const days = Math.floor(hrs / 24);
|
||||||
if (days < 7) return `${days}d ago`;
|
if (days < 7) return t`${days}d ago`;
|
||||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,8 +217,6 @@ export function Notifications() {
|
|||||||
const [state, setState] = useState<State>({ status: "loading" });
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 1. Fetch with original read state so unread items are highlighted
|
|
||||||
// 2. Only after displaying, mark all read on the server
|
|
||||||
authFetch(
|
authFetch(
|
||||||
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||||
)
|
)
|
||||||
@@ -231,7 +231,6 @@ export function Notifications() {
|
|||||||
page: 1,
|
page: 1,
|
||||||
loadingMore: false,
|
loadingMore: false,
|
||||||
});
|
});
|
||||||
// Mark read server-side after we've shown the unread state
|
|
||||||
return authFetch(`${API_URL}/api/notifications/read-all`, {
|
return authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@@ -251,17 +250,19 @@ export function Notifications() {
|
|||||||
setState({ status: "error", error: err.message });
|
setState({ status: "error", error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [authFetch, clearUnreadNotifications]);
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||||
if (!lastNotification) return;
|
lastNotification,
|
||||||
|
);
|
||||||
|
if (prevLastNotification !== lastNotification && lastNotification !== null) {
|
||||||
|
setPrevLastNotification(lastNotification);
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
if (s.items.some((n) => n.id === lastNotification.id)) return s;
|
if (s.items.some((n) => n.id === lastNotification.id)) return s;
|
||||||
// Keep as unread so it gets highlighted when it arrives
|
|
||||||
return { ...s, items: [lastNotification, ...s.items] };
|
return { ...s, items: [lastNotification, ...s.items] };
|
||||||
});
|
});
|
||||||
}, [lastNotification]);
|
}
|
||||||
|
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
|
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
|
||||||
@@ -304,27 +305,31 @@ export function Notifications() {
|
|||||||
<div className="notifications-header">
|
<div className="notifications-header">
|
||||||
<h1 className="notifications-title">
|
<h1 className="notifications-title">
|
||||||
<span className="notifications-title-bell">🔔</span>
|
<span className="notifications-title-bell">🔔</span>
|
||||||
Notifications
|
<Trans>Notifications</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
{state.status === "loaded" && totalUnread > 0 && (
|
{state.status === "loaded" && totalUnread > 0 && (
|
||||||
<span className="notifications-unread-pill">
|
<span className="notifications-unread-pill">
|
||||||
{totalUnread} new
|
{totalUnread} <Trans>new</Trans>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.status === "loading" && <p className="page-loading">Loading…</p>}
|
{state.status === "loading" && (
|
||||||
|
<p className="page-loading"><Trans>Loading…</Trans></p>
|
||||||
|
)}
|
||||||
{state.status === "error" && (
|
{state.status === "error" && (
|
||||||
<ErrorCard title="Failed to load" message={state.error} />
|
<ErrorCard title={t`Failed to load`} message={state.error} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "loaded" && state.items.length === 0 && (
|
{state.status === "loaded" && state.items.length === 0 && (
|
||||||
<div className="notifications-empty">
|
<div className="notifications-empty">
|
||||||
<span className="notifications-empty-icon">🔕</span>
|
<span className="notifications-empty-icon">🔕</span>
|
||||||
<p>Nothing here yet.</p>
|
<p><Trans>Nothing here yet.</Trans></p>
|
||||||
<p className="notifications-empty-hint">
|
<p className="notifications-empty-hint">
|
||||||
|
<Trans>
|
||||||
You'll be notified when someone follows your playlists, upvotes
|
You'll be notified when someone follows your playlists, upvotes
|
||||||
your dumps, or posts new content.
|
your dumps, or posts new content.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -332,7 +337,9 @@ export function Notifications() {
|
|||||||
{state.status === "loaded" && state.items.length > 0 &&
|
{state.status === "loaded" && state.items.length > 0 &&
|
||||||
groupByDate(state.items).map(({ label, items }) => (
|
groupByDate(state.items).map(({ label, items }) => (
|
||||||
<section key={label} className="notif-group">
|
<section key={label} className="notif-group">
|
||||||
<h2 className="notif-group-label">{label}</h2>
|
<h2 className="notif-group-label">
|
||||||
|
{label === "Today" ? t`Today` : label === "Yesterday" ? t`Yesterday` : t`Earlier`}
|
||||||
|
</h2>
|
||||||
<ul className="notification-list">
|
<ul className="notification-list">
|
||||||
{items.map((n) => (
|
{items.map((n) => (
|
||||||
<li
|
<li
|
||||||
@@ -376,7 +383,7 @@ export function Notifications() {
|
|||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
disabled={state.loadingMore}
|
disabled={state.loadingMore}
|
||||||
>
|
>
|
||||||
{state.loadingMore ? "Loading…" : "Load more"}
|
{state.loadingMore ? <Trans>Loading…</Trans> : <Trans>Load more</Trans>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type {
|
import type {
|
||||||
PlaylistWithDumps,
|
PlaylistWithDumps,
|
||||||
@@ -108,7 +116,7 @@ export function PlaylistDetail() {
|
|||||||
|
|
||||||
const fetchAbortRef = useRef<AbortController | null>(null);
|
const fetchAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const fetchPlaylist = () => {
|
const fetchPlaylist = useCallback(() => {
|
||||||
if (!playlistId) return;
|
if (!playlistId) return;
|
||||||
fetchAbortRef.current?.abort();
|
fetchAbortRef.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -152,12 +160,12 @@ export function PlaylistDetail() {
|
|||||||
error: friendlyFetchError(err),
|
error: friendlyFetchError(err),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
}, [playlistId, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
return () => fetchAbortRef.current?.abort();
|
return () => fetchAbortRef.current?.abort();
|
||||||
}, [playlistId]);
|
}, [fetchPlaylist]);
|
||||||
|
|
||||||
// Start the cooldown→dismissing→gone sequence for a dump being removed.
|
// Start the cooldown→dismissing→gone sequence for a dump being removed.
|
||||||
// After the sequence completes, the dump is removed from state.playlist.dumps.
|
// After the sequence completes, the dump is removed from state.playlist.dumps.
|
||||||
@@ -337,7 +345,7 @@ export function PlaylistDetail() {
|
|||||||
} else if (ev.type === "deleted") {
|
} else if (ev.type === "deleted") {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
}, [lastPlaylistEvent, playlistUUID]);
|
}, [lastPlaylistEvent, playlistUUID, navigate, token]);
|
||||||
|
|
||||||
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -579,7 +587,7 @@ export function PlaylistDetail() {
|
|||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<p className="page-loading">Loading playlist…</p>
|
<p className="page-loading"><Trans>Loading playlist…</Trans></p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -594,7 +602,7 @@ export function PlaylistDetail() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
>
|
>
|
||||||
← Back
|
<Trans>← Back</Trans>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -654,21 +662,21 @@ export function PlaylistDetail() {
|
|||||||
disabled={editSaving}
|
disabled={editSaving}
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
>
|
>
|
||||||
{editSaving ? "Saving…" : "Save"}
|
{editSaving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="form-cancel"
|
className="form-cancel"
|
||||||
onClick={() => setEditOpen(false)}
|
onClick={() => setEditOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-danger"
|
className="btn-danger"
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
>
|
>
|
||||||
Delete
|
<Trans>Delete</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -687,7 +695,7 @@ export function PlaylistDetail() {
|
|||||||
className="playlist-edit-btn"
|
className="playlist-edit-btn"
|
||||||
onClick={openEdit}
|
onClick={openEdit}
|
||||||
>
|
>
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -699,7 +707,7 @@ export function PlaylistDetail() {
|
|||||||
className="playlist-edit-textarea"
|
className="playlist-edit-textarea"
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={setEditDescription}
|
onChange={setEditDescription}
|
||||||
placeholder="Description (optional)"
|
placeholder={t`Description (optional)`}
|
||||||
autoResize
|
autoResize
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
@@ -719,14 +727,14 @@ export function PlaylistDetail() {
|
|||||||
className={editIsPublic ? "active" : ""}
|
className={editIsPublic ? "active" : ""}
|
||||||
onClick={() => setEditIsPublic(true)}
|
onClick={() => setEditIsPublic(true)}
|
||||||
>
|
>
|
||||||
Public
|
<Trans>Public</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={!editIsPublic ? "active" : ""}
|
className={!editIsPublic ? "active" : ""}
|
||||||
onClick={() => setEditIsPublic(false)}
|
onClick={() => setEditIsPublic(false)}
|
||||||
>
|
>
|
||||||
Private
|
<Trans>Private</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -737,7 +745,7 @@ export function PlaylistDetail() {
|
|||||||
playlist.isPublic ? "" : " playlist-badge--private"
|
playlist.isPublic ? "" : " playlist-badge--private"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{playlist.isPublic ? "public" : "private"}
|
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
|
||||||
</span>
|
</span>
|
||||||
{playlist.ownerUsername && (
|
{playlist.ownerUsername && (
|
||||||
<Link
|
<Link
|
||||||
@@ -754,10 +762,10 @@ export function PlaylistDetail() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
{playlist.updatedAt && (
|
{playlist.updatedAt && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={`Edited ${playlist.updatedAt.toLocaleString()}`}
|
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
|
||||||
>
|
>
|
||||||
<span className="playlist-edited-label">
|
<span className="playlist-edited-label">
|
||||||
edited {relativeTime(playlist.updatedAt)}
|
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -765,14 +773,14 @@ export function PlaylistDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editError && (
|
{editError && (
|
||||||
<ErrorCard title="Failed to save" message={editError} />
|
<ErrorCard title={t`Failed to save`} message={editError} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visibleDumps.length === 0
|
{visibleDumps.length === 0
|
||||||
? <p className="empty-state">No dumps in this playlist yet.</p>
|
? <p className="empty-state"><Trans>No dumps in this playlist yet.</Trans></p>
|
||||||
: (
|
: (
|
||||||
<div
|
<div
|
||||||
className="playlist-dump-list"
|
className="playlist-dump-list"
|
||||||
@@ -827,7 +835,7 @@ export function PlaylistDetail() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="playlist-remove-btn"
|
className="playlist-remove-btn"
|
||||||
onClick={() => handleRemoveDump(dump.id)}
|
onClick={() => handleRemoveDump(dump.id)}
|
||||||
aria-label="Remove from playlist"
|
aria-label={t`Remove from playlist`}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -837,9 +845,9 @@ export function PlaylistDetail() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="playlist-cancel-btn"
|
className="playlist-cancel-btn"
|
||||||
onClick={() => handleCancelRemove(dump.id)}
|
onClick={() => handleCancelRemove(dump.id)}
|
||||||
aria-label="Cancel removal"
|
aria-label={t`Cancel removal`}
|
||||||
>
|
>
|
||||||
Undo
|
<Trans>Undo</Trans>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -849,8 +857,8 @@ export function PlaylistDetail() {
|
|||||||
)}
|
)}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
message="Delete this playlist? This cannot be undone."
|
message={t`Delete this playlist? This cannot be undone.`}
|
||||||
confirmLabel="Delete playlist"
|
confirmLabel={t`Delete playlist`}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => setConfirmDelete(false)}
|
onCancel={() => setConfirmDelete(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router";
|
import { Link, useSearchParams } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { AppHeader } from "../components/AppHeader.tsx";
|
import { AppHeader } from "../components/AppHeader.tsx";
|
||||||
import { SearchBar } from "../components/SearchBar.tsx";
|
import { SearchBar } from "../components/SearchBar.tsx";
|
||||||
import { DumpCard } from "../components/DumpCard.tsx";
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
@@ -151,10 +153,10 @@ export function Search() {
|
|||||||
!state.dumps.loadingMore,
|
!state.dumps.loadingMore,
|
||||||
);
|
);
|
||||||
|
|
||||||
function setTab(t: Tab) {
|
function setTab(tab: Tab) {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev);
|
||||||
next.set("tab", t);
|
next.set("tab", tab);
|
||||||
return next;
|
return next;
|
||||||
}, { replace: true });
|
}, { replace: true });
|
||||||
}
|
}
|
||||||
@@ -165,13 +167,13 @@ export function Search() {
|
|||||||
? state.playlists.length
|
? state.playlists.length
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
function tabLabel(t: Tab, count: number | null) {
|
function tabLabel(tab: Tab, count: number | null) {
|
||||||
const label = t === "dumps"
|
const label = tab === "dumps"
|
||||||
? "Dumps"
|
? t`Dumps`
|
||||||
: t === "users"
|
: tab === "users"
|
||||||
? "Users"
|
? t`Users`
|
||||||
: "Playlists";
|
: t`Playlists`;
|
||||||
return count !== null ? `${label} (${count})` : label;
|
return count !== null ? t`${label} (${count})` : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,18 +182,18 @@ export function Search() {
|
|||||||
<main className="search-page">
|
<main className="search-page">
|
||||||
{q && (
|
{q && (
|
||||||
<div className="search-tabs">
|
<div className="search-tabs">
|
||||||
{(["dumps", "users", "playlists"] as Tab[]).map((t) => (
|
{(["dumps", "users", "playlists"] as Tab[]).map((tabKey) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={tabKey}
|
||||||
type="button"
|
type="button"
|
||||||
className={`feed-sort-btn${tab === t ? " active" : ""}`}
|
className={`feed-sort-btn${tab === tabKey ? " active" : ""}`}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(tabKey)}
|
||||||
>
|
>
|
||||||
{tabLabel(
|
{tabLabel(
|
||||||
t,
|
tabKey,
|
||||||
t === "dumps"
|
tabKey === "dumps"
|
||||||
? dumpCount
|
? dumpCount
|
||||||
: t === "users"
|
: tabKey === "users"
|
||||||
? userCount
|
? userCount
|
||||||
: playlistCount,
|
: playlistCount,
|
||||||
)}
|
)}
|
||||||
@@ -201,21 +203,21 @@ export function Search() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "idle" && (
|
{state.status === "idle" && (
|
||||||
<p className="search-page-empty">Enter a query to search.</p>
|
<p className="search-page-empty"><Trans>Enter a query to search.</Trans></p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "loading" && (
|
{state.status === "loading" && (
|
||||||
<p className="search-page-empty">Searching…</p>
|
<p className="search-page-empty"><Trans>Searching…</Trans></p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "error" && (
|
{state.status === "error" && (
|
||||||
<ErrorCard title="Search failed" message={state.error} />
|
<ErrorCard title={t`Search failed`} message={state.error} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "loaded" && tab === "dumps" && (
|
{state.status === "loaded" && tab === "dumps" && (
|
||||||
<>
|
<>
|
||||||
{state.dumps.items.length === 0
|
{state.dumps.items.length === 0
|
||||||
? <p className="search-page-empty">No dumps match "{q}".</p>
|
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{state.dumps.items.map((dump) => (
|
{state.dumps.items.map((dump) => (
|
||||||
@@ -234,17 +236,17 @@ export function Search() {
|
|||||||
)}
|
)}
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
{state.dumps.loadingMore && (
|
{state.dumps.loadingMore && (
|
||||||
<p className="feed-loading-more">Loading more…</p>
|
<p className="feed-loading-more"><Trans>Loading more…</Trans></p>
|
||||||
)}
|
)}
|
||||||
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
|
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
|
||||||
<p className="feed-end">You've reached the end.</p>
|
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "loaded" && tab === "users" && (
|
{state.status === "loaded" && tab === "users" && (
|
||||||
state.users.length === 0
|
state.users.length === 0
|
||||||
? <p className="search-page-empty">No users match "{q}".</p>
|
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
|
||||||
: (
|
: (
|
||||||
<ul className="user-results">
|
<ul className="user-results">
|
||||||
{state.users.map((u) => (
|
{state.users.map((u) => (
|
||||||
@@ -268,7 +270,7 @@ export function Search() {
|
|||||||
|
|
||||||
{state.status === "loaded" && tab === "playlists" && (
|
{state.status === "loaded" && tab === "playlists" && (
|
||||||
state.playlists.length === 0
|
state.playlists.length === 0
|
||||||
? <p className="search-page-empty">No playlists match "{q}".</p>
|
? <p className="search-page-empty">{t`No playlists match "${q}".`}</p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{state.playlists.map((p) => (
|
{state.playlists.map((p) => (
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans, Plural } from "@lingui/react/macro";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
@@ -45,7 +47,7 @@ export function UserDumps() {
|
|||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<p className="page-loading">Loading…</p>
|
<p className="page-loading"><Trans>Loading…</Trans></p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,7 +58,7 @@ export function UserDumps() {
|
|||||||
message={state.error}
|
message={state.error}
|
||||||
actions={
|
actions={
|
||||||
<Link to={`/users/${username}`} className="btn-border">
|
<Link to={`/users/${username}`} className="btn-border">
|
||||||
← Back to profile
|
<Trans>← Back to profile</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -70,14 +72,14 @@ export function UserDumps() {
|
|||||||
<ProfileSubpageHeader
|
<ProfileSubpageHeader
|
||||||
username={username!}
|
username={username!}
|
||||||
profileUser={profileUser}
|
profileUser={profileUser}
|
||||||
title="Dumps"
|
title={t`Dumps`}
|
||||||
actions={isOwnProfile && (
|
actions={isOwnProfile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="new-playlist-toggle"
|
className="new-playlist-toggle"
|
||||||
onClick={() => setCreateModalOpen(true)}
|
onClick={() => setCreateModalOpen(true)}
|
||||||
>
|
>
|
||||||
+ New dump
|
<Trans>+ New dump</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -87,7 +89,7 @@ export function UserDumps() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{dumps.length === 0
|
{dumps.length === 0
|
||||||
? <p className="empty-state">Nothing here yet.</p>
|
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{dumps.map((dump) => (
|
{dumps.map((dump) => (
|
||||||
@@ -106,9 +108,11 @@ export function UserDumps() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
{loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||||
{!hasMore && dumps.length > 0 && (
|
{!hasMore && dumps.length > 0 && (
|
||||||
<p className="index-status">All {dumps.length} dumps loaded.</p>
|
<p className="index-status">
|
||||||
|
<Trans>All <Plural value={dumps.length} one="# dump" other="# dumps" /> loaded.</Trans>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { SubmitEvent } from "react";
|
import type { SubmitEvent } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import {
|
import {
|
||||||
@@ -57,17 +59,17 @@ export function UserLogin() {
|
|||||||
return (
|
return (
|
||||||
<PageShell centered>
|
<PageShell centered>
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<h1 className="auth-card-title">Log in</h1>
|
<h1 className="auth-card-title"><Trans>Log in</Trans></h1>
|
||||||
|
|
||||||
{state.status === "error" && (
|
{state.status === "error" && (
|
||||||
<ErrorCard title="Login failed" message={state.error} />
|
<ErrorCard title={t`Login failed`} message={state.error} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
<input
|
<input
|
||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder={t`Username`}
|
||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={state.status === "submitting"}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -75,7 +77,7 @@ export function UserLogin() {
|
|||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder={t`Password`}
|
||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={state.status === "submitting"}
|
||||||
/>
|
/>
|
||||||
@@ -84,12 +86,14 @@ export function UserLogin() {
|
|||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={state.status === "submitting"}
|
disabled={state.status === "submitting"}
|
||||||
>
|
>
|
||||||
{state.status === "submitting" ? "Logging in…" : "Log in"}
|
{state.status === "submitting"
|
||||||
|
? <Trans>Logging in…</Trans>
|
||||||
|
: <Trans>Log in</Trans>}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="auth-card-footer">
|
<p className="auth-card-footer">
|
||||||
This is a mirage.
|
<Trans>This is a mirage.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
@@ -74,6 +76,11 @@ export function UserPlaylists() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [state, setState] = useState<State>({ status: "loading" });
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
const [prevUsername, setPrevUsername] = useState(username);
|
||||||
|
if (prevUsername !== username) {
|
||||||
|
setPrevUsername(username);
|
||||||
|
setState({ status: "loading" });
|
||||||
|
}
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||||
@@ -114,7 +121,6 @@ export function UserPlaylists() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const authHeaders: HeadersInit = token
|
const authHeaders: HeadersInit = token
|
||||||
@@ -190,7 +196,7 @@ export function UserPlaylists() {
|
|||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [username]);
|
}, [username, cachedCreated, cachedFollowed, token]);
|
||||||
|
|
||||||
const loadMoreCreated = useCallback(() => {
|
const loadMoreCreated = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@@ -332,7 +338,7 @@ export function UserPlaylists() {
|
|||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<p className="page-loading">Loading…</p>
|
<p className="page-loading"><Trans>Loading…</Trans></p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -343,7 +349,7 @@ export function UserPlaylists() {
|
|||||||
message={state.error}
|
message={state.error}
|
||||||
actions={
|
actions={
|
||||||
<Link to={`/users/${username}`} className="btn-border">
|
<Link to={`/users/${username}`} className="btn-border">
|
||||||
← Back to profile
|
<Trans>← Back to profile</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -357,7 +363,7 @@ export function UserPlaylists() {
|
|||||||
<ProfileSubpageHeader
|
<ProfileSubpageHeader
|
||||||
username={username!}
|
username={username!}
|
||||||
profileUser={profileUser}
|
profileUser={profileUser}
|
||||||
title="Playlists"
|
title={t`Playlists`}
|
||||||
actions={isOwnProfile && (
|
actions={isOwnProfile && (
|
||||||
<NewPlaylistForm
|
<NewPlaylistForm
|
||||||
toggleClassName="btn-primary"
|
toggleClassName="btn-primary"
|
||||||
@@ -377,12 +383,13 @@ export function UserPlaylists() {
|
|||||||
<section className="profile-section">
|
<section className="profile-section">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
<h2 className="profile-section-title">
|
<h2 className="profile-section-title">
|
||||||
Created ({created.items.length}
|
<Trans>
|
||||||
{created.hasMore ? "+" : ""})
|
Created ({created.items.length}{created.hasMore ? "+" : ""})
|
||||||
|
</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{created.items.length === 0
|
{created.items.length === 0
|
||||||
? <p className="empty-state">No playlists yet.</p>
|
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{created.items.map((p) => (
|
{created.items.map((p) => (
|
||||||
@@ -399,19 +406,24 @@ export function UserPlaylists() {
|
|||||||
)}
|
)}
|
||||||
<div ref={createdSentinelRef} />
|
<div ref={createdSentinelRef} />
|
||||||
{created.loadingMore && (
|
{created.loadingMore && (
|
||||||
<p className="feed-loading-more">Loading more…</p>
|
<p className="feed-loading-more"><Trans>Loading more…</Trans></p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="profile-section">
|
<section className="profile-section">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
<h2 className="profile-section-title">
|
<h2 className="profile-section-title">
|
||||||
Followed ({followed.items.length}
|
<Trans>
|
||||||
{followed.hasMore ? "+" : ""})
|
Followed ({followed.items.length}{followed.hasMore ? "+" : ""})
|
||||||
|
</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{followed.items.length === 0
|
{followed.items.length === 0
|
||||||
? <p className="empty-state">No followed playlists yet.</p>
|
? (
|
||||||
|
<p className="empty-state">
|
||||||
|
<Trans>No followed playlists yet.</Trans>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{followed.items.map((p) => (
|
{followed.items.map((p) => (
|
||||||
@@ -421,14 +433,14 @@ export function UserPlaylists() {
|
|||||||
)}
|
)}
|
||||||
<div ref={followedSentinelRef} />
|
<div ref={followedSentinelRef} />
|
||||||
{followed.loadingMore && (
|
{followed.loadingMore && (
|
||||||
<p className="feed-loading-more">Loading more…</p>
|
<p className="feed-loading-more"><Trans>Loading more…</Trans></p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{confirmDeleteId && (
|
{confirmDeleteId && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
message="Delete this playlist? This cannot be undone."
|
message={t`Delete this playlist? This cannot be undone.`}
|
||||||
confirmLabel="Delete playlist"
|
confirmLabel={t`Delete playlist`}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
handleDelete(confirmDeleteId);
|
handleDelete(confirmDeleteId);
|
||||||
setConfirmDeleteId(null);
|
setConfirmDeleteId(null);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
||||||
@@ -57,10 +59,10 @@ function InviteButton() {
|
|||||||
`${globalThis.location.origin}/register?token=${body.data.token}`;
|
`${globalThis.location.origin}/register?token=${body.data.token}`;
|
||||||
setInviteUrl(url);
|
setInviteUrl(url);
|
||||||
} else {
|
} else {
|
||||||
setError("Failed to generate invite");
|
setError(t`Failed to generate invite`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to generate invite");
|
setError(t`Failed to generate invite`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ function InviteButton() {
|
|||||||
<div className="invite-result">
|
<div className="invite-result">
|
||||||
<span className="invite-url">{inviteUrl}</span>
|
<span className="invite-url">{inviteUrl}</span>
|
||||||
<button type="button" className="invite-copy-btn" onClick={copy}>
|
<button type="button" className="invite-copy-btn" onClick={copy}>
|
||||||
{copied ? "Copied!" : "Copy"}
|
{copied ? t`Copied!` : t`Copy`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -85,9 +87,9 @@ function InviteButton() {
|
|||||||
return (
|
return (
|
||||||
<div className="invite-generate">
|
<div className="invite-generate">
|
||||||
<button type="button" className="invite-btn" onClick={generate}>
|
<button type="button" className="invite-btn" onClick={generate}>
|
||||||
+ Invite someone
|
<Trans>+ Invite someone</Trans>
|
||||||
</button>
|
</button>
|
||||||
{error && <ErrorCard title="Failed to generate invite" message={error} />}
|
{error && <ErrorCard title={t`Failed to generate invite`} message={error} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -240,10 +242,15 @@ export function UserPublicProfile() {
|
|||||||
const [emailError, setEmailError] = useState<string | null>(null);
|
const [emailError, setEmailError] = useState<string | null>(null);
|
||||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevUsername, setPrevUsername] = useState(username);
|
||||||
if (!username) return;
|
if (prevUsername !== username) {
|
||||||
|
setPrevUsername(username);
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
prevMyVotesRef.current = null;
|
prevMyVotesRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
|
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
|
||||||
@@ -358,7 +365,7 @@ export function UserPublicProfile() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [username]);
|
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
|
||||||
|
|
||||||
// Own profile: prepend dumps newly voted by the user to the preview list
|
// Own profile: prepend dumps newly voted by the user to the preview list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -505,7 +512,7 @@ export function UserPublicProfile() {
|
|||||||
: prev
|
: prev
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setAvatarError("Upload failed");
|
setAvatarError(t`Upload failed`);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -536,7 +543,7 @@ export function UserPublicProfile() {
|
|||||||
}
|
}
|
||||||
setEmailEditing(false);
|
setEmailEditing(false);
|
||||||
} catch {
|
} catch {
|
||||||
setEmailError("Failed to save");
|
setEmailError(t`Failed to save`);
|
||||||
} finally {
|
} finally {
|
||||||
setEmailSaving(false);
|
setEmailSaving(false);
|
||||||
}
|
}
|
||||||
@@ -571,7 +578,7 @@ export function UserPublicProfile() {
|
|||||||
);
|
);
|
||||||
setDescEditing(false);
|
setDescEditing(false);
|
||||||
} catch {
|
} catch {
|
||||||
setDescError("Failed to save");
|
setDescError(t`Failed to save`);
|
||||||
} finally {
|
} finally {
|
||||||
setDescSaving(false);
|
setDescSaving(false);
|
||||||
}
|
}
|
||||||
@@ -580,7 +587,7 @@ export function UserPublicProfile() {
|
|||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<p className="page-loading">Loading profile…</p>
|
<p className="page-loading"><Trans>Loading profile…</Trans></p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -596,11 +603,11 @@ export function UserPublicProfile() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
>
|
>
|
||||||
← Back
|
<Trans>← Back</Trans>
|
||||||
</button>
|
</button>
|
||||||
{me && (
|
{me && (
|
||||||
<button className="btn-border" type="button" onClick={logout}>
|
<button className="btn-border" type="button" onClick={logout}>
|
||||||
Log out
|
<Trans>Log out</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -623,7 +630,7 @@ export function UserPublicProfile() {
|
|||||||
version={profileUser.updatedAt?.getTime()}
|
version={profileUser.updatedAt?.getTime()}
|
||||||
/>
|
/>
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<label className="avatar-change-overlay" title="Change avatar">
|
<label className="avatar-change-overlay" title={t`Change avatar`}>
|
||||||
{uploading ? "…" : "✎"}
|
{uploading ? "…" : "✎"}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -641,7 +648,7 @@ export function UserPublicProfile() {
|
|||||||
{profileUser.invitedByUsername
|
{profileUser.invitedByUsername
|
||||||
? (
|
? (
|
||||||
<p className="profile-invited-by">
|
<p className="profile-invited-by">
|
||||||
invited by{" "}
|
<Trans>invited by</Trans>{" "}
|
||||||
<Link
|
<Link
|
||||||
to={`/users/${profileUser.invitedByUsername}`}
|
to={`/users/${profileUser.invitedByUsername}`}
|
||||||
className="profile-invited-by-link"
|
className="profile-invited-by-link"
|
||||||
@@ -682,7 +689,7 @@ export function UserPublicProfile() {
|
|||||||
className="profile-email-btn profile-email-btn--save"
|
className="profile-email-btn profile-email-btn--save"
|
||||||
disabled={emailSaving || !emailDraft.trim()}
|
disabled={emailSaving || !emailDraft.trim()}
|
||||||
>
|
>
|
||||||
{emailSaving ? "Saving…" : "Save"}
|
{emailSaving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -690,11 +697,11 @@ export function UserPublicProfile() {
|
|||||||
onClick={() => setEmailEditing(false)}
|
onClick={() => setEmailEditing(false)}
|
||||||
disabled={emailSaving}
|
disabled={emailSaving}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{emailError && (
|
{emailError && (
|
||||||
<ErrorCard title="Failed to save" message={emailError} />
|
<ErrorCard title={t`Failed to save`} message={emailError} />
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
@@ -708,7 +715,7 @@ export function UserPublicProfile() {
|
|||||||
}}
|
}}
|
||||||
title="Edit email"
|
title="Edit email"
|
||||||
>
|
>
|
||||||
{me?.email ?? "Add email…"}
|
{me?.email ?? t`Add email…`}
|
||||||
<span className="profile-description-edit-btn" aria-hidden>
|
<span className="profile-description-edit-btn" aria-hidden>
|
||||||
✎
|
✎
|
||||||
</span>
|
</span>
|
||||||
@@ -716,7 +723,7 @@ export function UserPublicProfile() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{avatarError && (
|
{avatarError && (
|
||||||
<ErrorCard title="Failed to update avatar" message={avatarError} />
|
<ErrorCard title={t`Failed to update avatar`} message={avatarError} />
|
||||||
)}
|
)}
|
||||||
{!isOwnProfile && (
|
{!isOwnProfile && (
|
||||||
<FollowUserButton
|
<FollowUserButton
|
||||||
@@ -728,7 +735,7 @@ export function UserPublicProfile() {
|
|||||||
<div className="profile-own-actions">
|
<div className="profile-own-actions">
|
||||||
<InviteButton />
|
<InviteButton />
|
||||||
<button type="button" className="btn-border" onClick={logout}>
|
<button type="button" className="btn-border" onClick={logout}>
|
||||||
Log out
|
<Trans>Log out</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -745,7 +752,7 @@ export function UserPublicProfile() {
|
|||||||
value={descDraft}
|
value={descDraft}
|
||||||
onChange={setDescDraft}
|
onChange={setDescDraft}
|
||||||
onSubmit={handleDescSave}
|
onSubmit={handleDescSave}
|
||||||
placeholder="Tell people about yourself…"
|
placeholder={t`Tell people about yourself…`}
|
||||||
autoResize
|
autoResize
|
||||||
/>
|
/>
|
||||||
<div className="profile-description-actions">
|
<div className="profile-description-actions">
|
||||||
@@ -755,7 +762,7 @@ export function UserPublicProfile() {
|
|||||||
onClick={handleDescSave}
|
onClick={handleDescSave}
|
||||||
disabled={descSaving}
|
disabled={descSaving}
|
||||||
>
|
>
|
||||||
{descSaving ? "Saving…" : "Save"}
|
{descSaving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -763,10 +770,10 @@ export function UserPublicProfile() {
|
|||||||
onClick={() => setDescEditing(false)}
|
onClick={() => setDescEditing(false)}
|
||||||
disabled={descSaving}
|
disabled={descSaving}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</button>
|
</button>
|
||||||
{descError && (
|
{descError && (
|
||||||
<ErrorCard title="Failed to save" message={descError} />
|
<ErrorCard title={t`Failed to save`} message={descError} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -792,7 +799,7 @@ export function UserPublicProfile() {
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div className="profile-description-empty">
|
<div className="profile-description-empty">
|
||||||
Add a bio…
|
<Trans>Add a bio…</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
@@ -807,7 +814,7 @@ export function UserPublicProfile() {
|
|||||||
|
|
||||||
<div className="profile-columns">
|
<div className="profile-columns">
|
||||||
<DumpList
|
<DumpList
|
||||||
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
|
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
|
||||||
dumps={dumps.items}
|
dumps={dumps.items}
|
||||||
voteCounts={voteCounts}
|
voteCounts={voteCounts}
|
||||||
myVotes={myVotes}
|
myVotes={myVotes}
|
||||||
@@ -819,7 +826,7 @@ export function UserPublicProfile() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<UpvotedDumpList
|
<UpvotedDumpList
|
||||||
title={`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
|
title={t`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
|
||||||
dumps={votes.items}
|
dumps={votes.items}
|
||||||
profileUserId={profileUserId}
|
profileUserId={profileUserId}
|
||||||
isOwnProfile={isOwnProfile}
|
isOwnProfile={isOwnProfile}
|
||||||
@@ -835,8 +842,7 @@ export function UserPublicProfile() {
|
|||||||
<section className="profile-section" id="playlists">
|
<section className="profile-section" id="playlists">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
<h2 className="profile-section-title">
|
<h2 className="profile-section-title">
|
||||||
Playlists ({playlists.items.length}
|
<Trans>Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})</Trans>
|
||||||
{playlists.hasMore ? "+" : ""})
|
|
||||||
</h2>
|
</h2>
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<NewPlaylistForm
|
<NewPlaylistForm
|
||||||
@@ -856,7 +862,7 @@ export function UserPublicProfile() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{playlists.items.length === 0
|
{playlists.items.length === 0
|
||||||
? <p className="empty-state">No playlists yet.</p>
|
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{playlists.items.map((p) => (
|
{playlists.items.map((p) => (
|
||||||
@@ -869,7 +875,7 @@ export function UserPublicProfile() {
|
|||||||
to={`/users/${profileUser.username}/playlists`}
|
to={`/users/${profileUser.username}/playlists`}
|
||||||
className="profile-view-all"
|
className="profile-view-all"
|
||||||
>
|
>
|
||||||
View all →
|
<Trans>View all →</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -913,7 +919,7 @@ function DumpList(
|
|||||||
className="new-playlist-toggle"
|
className="new-playlist-toggle"
|
||||||
onClick={() => setCreateModalOpen(true)}
|
onClick={() => setCreateModalOpen(true)}
|
||||||
>
|
>
|
||||||
+ New dump
|
<Trans>+ New dump</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -921,7 +927,7 @@ function DumpList(
|
|||||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||||
)}
|
)}
|
||||||
{dumps.length === 0
|
{dumps.length === 0
|
||||||
? <p className="empty-state">Nothing here yet.</p>
|
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{dumps.map((dump) => (
|
{dumps.map((dump) => (
|
||||||
@@ -939,7 +945,7 @@ function DumpList(
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{dumps.length > 0 && (
|
{dumps.length > 0 && (
|
||||||
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
<Link to={viewAllHref} className="profile-view-all"><Trans>View all →</Trans></Link>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -985,9 +991,17 @@ function UpvotedDumpList(
|
|||||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
|
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
|
||||||
|
// setVotedIds and startFading/cancelFading must be called together synchronously
|
||||||
|
// in the same effect to guarantee a single render where the DOM node isn't
|
||||||
|
// unmounted — converting to render-phase isn't possible because startFading/
|
||||||
|
// cancelFading are themselves setState calls that can't run during render.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!profileUserId || !isOwnProfile) return;
|
if (!profileUserId || !isOwnProfile) return;
|
||||||
if (prevMyVotesRef.current === null) {
|
if (prevMyVotesRef.current === null) {
|
||||||
|
// setVotedIds must fire here alongside prevMyVotesRef mutation; render-phase
|
||||||
|
// isn't possible because startFading/cancelFading (below) are also setState
|
||||||
|
// calls that cannot be invoked during render.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setVotedIds(new Set(wsMyVotes));
|
setVotedIds(new Set(wsMyVotes));
|
||||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||||
return;
|
return;
|
||||||
@@ -1000,11 +1014,16 @@ function UpvotedDumpList(
|
|||||||
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
|
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
|
||||||
|
|
||||||
// Non-own profile: sync votedIds with WS vote events for the profile user.
|
// Non-own profile: sync votedIds with WS vote events for the profile user.
|
||||||
|
// Same constraint as above: setVotedIds and startFading/cancelFading must
|
||||||
|
// fire together so the DOM node stays mounted throughout the transition.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
|
if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
|
||||||
const { dumpId, voterId, action } = lastVoteEvent;
|
const { dumpId, voterId, action } = lastVoteEvent;
|
||||||
if (voterId !== profileUserId) return;
|
if (voterId !== profileUserId) return;
|
||||||
if (action === "remove") {
|
if (action === "remove") {
|
||||||
|
// setVotedIds + startFading must be coordinated in the same effect body
|
||||||
|
// to guarantee a single render — render-phase can't call startFading (setState).
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setVotedIds((prev) => {
|
setVotedIds((prev) => {
|
||||||
const n = new Set(prev);
|
const n = new Set(prev);
|
||||||
n.delete(dumpId);
|
n.delete(dumpId);
|
||||||
@@ -1027,7 +1046,7 @@ function UpvotedDumpList(
|
|||||||
<h2 className="profile-section-title">{title}</h2>
|
<h2 className="profile-section-title">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
{visibleDumps.length === 0
|
{visibleDumps.length === 0
|
||||||
? <p className="empty-state">Nothing here yet.</p>
|
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{visibleDumps.map((dump) => {
|
{visibleDumps.map((dump) => {
|
||||||
@@ -1054,7 +1073,7 @@ function UpvotedDumpList(
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{visibleDumps.length > 0 && (
|
{visibleDumps.length > 0 && (
|
||||||
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
<Link to={viewAllHref} className="profile-view-all"><Trans>View all →</Trans></Link>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { SubmitEvent } from "react";
|
import type { SubmitEvent } from "react";
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
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 { API_URL, VALIDATION } from "../config/api.ts";
|
||||||
import {
|
import {
|
||||||
@@ -30,16 +32,19 @@ export function UserRegister() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const token = searchParams.get("token") ?? "";
|
const token = searchParams.get("token") ?? "";
|
||||||
|
|
||||||
const [tokenState, setTokenState] = useState<TokenState>({
|
const [tokenState, setTokenState] = useState<TokenState>(() =>
|
||||||
status: "checking",
|
token ? { status: "checking" } : { status: "invalid" }
|
||||||
});
|
);
|
||||||
const [formState, setFormState] = useState<FormState>({ status: "idle" });
|
const [formState, setFormState] = useState<FormState>({ status: "idle" });
|
||||||
|
const [prevToken, setPrevToken] = useState(token);
|
||||||
|
|
||||||
|
if (prevToken !== token) {
|
||||||
|
setPrevToken(token);
|
||||||
|
setTokenState(token ? { status: "checking" } : { status: "invalid" });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) return;
|
||||||
setTokenState({ status: "invalid" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
|
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
|
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
|
||||||
@@ -86,7 +91,7 @@ export function UserRegister() {
|
|||||||
if (tokenState.status === "checking") {
|
if (tokenState.status === "checking") {
|
||||||
return (
|
return (
|
||||||
<PageShell centered>
|
<PageShell centered>
|
||||||
<p className="page-loading">Checking invite…</p>
|
<p className="page-loading"><Trans>Checking invite…</Trans></p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,8 +101,8 @@ export function UserRegister() {
|
|||||||
<PageShell centered>
|
<PageShell centered>
|
||||||
<div className="page-error-wrap">
|
<div className="page-error-wrap">
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
title="Invalid invite"
|
title={t`Invalid invite`}
|
||||||
message="This invite link is missing, expired, or already used."
|
message={t`This invite link is missing, expired, or already used.`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
@@ -107,34 +112,34 @@ export function UserRegister() {
|
|||||||
return (
|
return (
|
||||||
<PageShell centered>
|
<PageShell centered>
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<h1 className="auth-card-title">Register</h1>
|
<h1 className="auth-card-title"><Trans>Register</Trans></h1>
|
||||||
|
|
||||||
{formState.status === "error" && (
|
{formState.status === "error" && (
|
||||||
<ErrorCard title="Registration failed" message={formState.error} />
|
<ErrorCard title={t`Registration failed`} message={formState.error} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
<input
|
<input
|
||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder={t`Username`}
|
||||||
required
|
required
|
||||||
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
|
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
|
||||||
title={`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
|
title={t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
|
||||||
disabled={formState.status === "submitting"}
|
disabled={formState.status === "submitting"}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Email address"
|
placeholder={t`Email address`}
|
||||||
required
|
required
|
||||||
disabled={formState.status === "submitting"}
|
disabled={formState.status === "submitting"}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
|
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
|
||||||
required
|
required
|
||||||
minLength={VALIDATION.PASSWORD_MIN}
|
minLength={VALIDATION.PASSWORD_MIN}
|
||||||
maxLength={VALIDATION.PASSWORD_MAX}
|
maxLength={VALIDATION.PASSWORD_MAX}
|
||||||
@@ -145,12 +150,14 @@ export function UserRegister() {
|
|||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={formState.status === "submitting"}
|
disabled={formState.status === "submitting"}
|
||||||
>
|
>
|
||||||
{formState.status === "submitting" ? "Registering…" : "Register"}
|
{formState.status === "submitting"
|
||||||
|
? <Trans>Registering…</Trans>
|
||||||
|
: <Trans>Register</Trans>}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="auth-card-footer">
|
<p className="auth-card-footer">
|
||||||
Already have an account? <Link to="/login">Log in</Link>
|
<Trans>Already have an account? <Link to="/login">Log in</Link></Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
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";
|
||||||
@@ -38,23 +40,36 @@ export function UserUpvoted() {
|
|||||||
|
|
||||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||||
|
|
||||||
// Reset vote tracking when username changes
|
const [prevUsername, setPrevUsername] = useState(username);
|
||||||
|
if (prevUsername !== username) {
|
||||||
|
setPrevUsername(username);
|
||||||
|
setVotedIds(new Set());
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cancelAll();
|
cancelAll();
|
||||||
setVotedIds(new Set());
|
|
||||||
prevMyVotesRef.current = null;
|
prevMyVotesRef.current = null;
|
||||||
}, [username]);
|
}, [username, cancelAll]);
|
||||||
|
|
||||||
// Seed votedIds once items are loaded
|
const [prevStateStatus, setPrevStateStatus] = useState(state.status);
|
||||||
useEffect(() => {
|
const [prevStateItems, setPrevStateItems] = useState(
|
||||||
if (state.status !== "loaded") return;
|
state.status === "loaded" ? state.items : null,
|
||||||
|
);
|
||||||
|
const currentItems = state.status === "loaded" ? state.items : null;
|
||||||
|
if (
|
||||||
|
prevStateStatus !== state.status ||
|
||||||
|
prevStateItems !== currentItems
|
||||||
|
) {
|
||||||
|
setPrevStateStatus(state.status);
|
||||||
|
setPrevStateItems(currentItems);
|
||||||
|
if (state.status === "loaded") {
|
||||||
setVotedIds(new Set(state.items.map((d) => d.id)));
|
setVotedIds(new Set(state.items.map((d) => d.id)));
|
||||||
}, [state.status]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Own profile: keep votedIds in sync with myVotes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!profileUserId || me?.id !== profileUserId) return;
|
if (!profileUserId || me?.id !== profileUserId) return;
|
||||||
if (prevMyVotesRef.current === null) {
|
if (prevMyVotesRef.current === null) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setVotedIds(new Set(myVotes));
|
setVotedIds(new Set(myVotes));
|
||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
return;
|
return;
|
||||||
@@ -66,13 +81,13 @@ export function UserUpvoted() {
|
|||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
}, [myVotes, me, profileUserId, startFading, cancelFading]);
|
}, [myVotes, me, profileUserId, startFading, cancelFading]);
|
||||||
|
|
||||||
// WS vote events
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastVoteEvent || !profileUserId) return;
|
if (!lastVoteEvent || !profileUserId) return;
|
||||||
const { dumpId, voterId, action } = lastVoteEvent;
|
const { dumpId, voterId, action } = lastVoteEvent;
|
||||||
if (voterId !== profileUserId) return;
|
if (voterId !== profileUserId) return;
|
||||||
|
|
||||||
if (action === "remove") {
|
if (action === "remove") {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setVotedIds((prev) => {
|
setVotedIds((prev) => {
|
||||||
const n = new Set(prev);
|
const n = new Set(prev);
|
||||||
n.delete(dumpId);
|
n.delete(dumpId);
|
||||||
@@ -96,12 +111,12 @@ export function UserUpvoted() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
|
}, [lastVoteEvent, profileUserId, startFading, cancelFading, setState]);
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<p className="page-loading">Loading…</p>
|
<p className="page-loading"><Trans>Loading…</Trans></p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,7 +127,7 @@ export function UserUpvoted() {
|
|||||||
message={state.error}
|
message={state.error}
|
||||||
actions={
|
actions={
|
||||||
<Link to={`/users/${username}`} className="btn-border">
|
<Link to={`/users/${username}`} className="btn-border">
|
||||||
← Back to profile
|
<Trans>← Back to profile</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -129,11 +144,11 @@ export function UserUpvoted() {
|
|||||||
<ProfileSubpageHeader
|
<ProfileSubpageHeader
|
||||||
username={username!}
|
username={username!}
|
||||||
profileUser={profileUser}
|
profileUser={profileUser}
|
||||||
title="Upvoted"
|
title={t`Upvoted`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{visibleDumps.length === 0
|
{visibleDumps.length === 0
|
||||||
? <p className="empty-state">Nothing here yet.</p>
|
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{visibleDumps.map((dump) => {
|
{visibleDumps.map((dump) => {
|
||||||
@@ -161,9 +176,13 @@ export function UserUpvoted() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
{loadingMore && (
|
||||||
|
<p className="feed-loading-more"><Trans>Loading more…</Trans></p>
|
||||||
|
)}
|
||||||
{!hasMore && visibleDumps.length > 0 && (
|
{!hasMore && visibleDumps.length > 0 && (
|
||||||
<p className="index-status">All {votes.length} upvoted dumps loaded.</p>
|
<p className="index-status">
|
||||||
|
<Trans>All <Plural value={votes.length} one="# upvoted dump" other="# upvoted dumps" /> loaded.</Trans>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||||
@@ -69,10 +71,10 @@ function FollowedSubFeed({
|
|||||||
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
|
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return <p className="index-status">Loading…</p>;
|
return <p className="index-status"><Trans>Loading…</Trans></p>;
|
||||||
}
|
}
|
||||||
if (state.status === "error") {
|
if (state.status === "error") {
|
||||||
return <ErrorCard title="Failed to load" message={state.error} />;
|
return <ErrorCard title={t`Failed to load`} message={state.error} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
|
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
|
||||||
@@ -98,7 +100,7 @@ function FollowedSubFeed({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
{state.loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
{state.loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -320,14 +322,14 @@ export function FollowedFeed({
|
|||||||
className={`feed-sort-btn${section === "users" ? " active" : ""}`}
|
className={`feed-sort-btn${section === "users" ? " active" : ""}`}
|
||||||
onClick={() => setSection("users")}
|
onClick={() => setSection("users")}
|
||||||
>
|
>
|
||||||
From people
|
<Trans>From people</Trans>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`feed-sort-btn${section === "playlists" ? " active" : ""}`}
|
className={`feed-sort-btn${section === "playlists" ? " active" : ""}`}
|
||||||
onClick={() => setSection("playlists")}
|
onClick={() => setSection("playlists")}
|
||||||
>
|
>
|
||||||
From playlists
|
<Trans>From playlists</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,7 +342,7 @@ export function FollowedFeed({
|
|||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
deletedDumpIds={deletedDumpIds}
|
deletedDumpIds={deletedDumpIds}
|
||||||
emptyMessage="Follow some users to see their dumps here."
|
emptyMessage={t`Follow some users to see their dumps here.`}
|
||||||
onLoadMore={loadMoreUsers}
|
onLoadMore={loadMoreUsers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -354,7 +356,7 @@ export function FollowedFeed({
|
|||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
deletedDumpIds={deletedDumpIds}
|
deletedDumpIds={deletedDumpIds}
|
||||||
emptyMessage="Follow some public playlists to see their dumps here."
|
emptyMessage={t`Follow some public playlists to see their dumps here.`}
|
||||||
onLoadMore={loadMorePlaylists}
|
onLoadMore={loadMorePlaylists}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||||
import { hotScore } from "../../utils/hotScore.ts";
|
import { hotScore } from "../../utils/hotScore.ts";
|
||||||
@@ -24,10 +26,10 @@ export function HotFeed(
|
|||||||
[dumps],
|
[dumps],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) return <p className="index-status">Loading…</p>;
|
if (loading) return <p className="index-status"><Trans>Loading…</Trans></p>;
|
||||||
if (error) return <ErrorCard title="Failed to load" message={error} />;
|
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||||
if (sorted.length === 0) {
|
if (sorted.length === 0) {
|
||||||
return <p className="index-status">No dumps yet. Be the first!</p>;
|
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,9 +49,9 @@ export function HotFeed(
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
{loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||||
{!hasMore && sorted.length > 0 && (
|
{!hasMore && sorted.length > 0 && (
|
||||||
<p className="feed-end">You've reached the end.</p>
|
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||||
import {
|
import {
|
||||||
JournalCard,
|
JournalCard,
|
||||||
@@ -36,10 +38,10 @@ export function JournalFeed(
|
|||||||
});
|
});
|
||||||
}, [dumps]);
|
}, [dumps]);
|
||||||
|
|
||||||
if (loading) return <p className="index-status">Loading…</p>;
|
if (loading) return <p className="index-status"><Trans>Loading…</Trans></p>;
|
||||||
if (error) return <ErrorCard title="Failed to load" message={error} />;
|
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||||
if (tiered.length === 0) {
|
if (tiered.length === 0) {
|
||||||
return <p className="index-status">No dumps yet. Be the first!</p>;
|
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,9 +62,9 @@ export function JournalFeed(
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
{loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||||
{!hasMore && tiered.length > 0 && (
|
{!hasMore && tiered.length > 0 && (
|
||||||
<p className="feed-end">You've reached the end.</p>
|
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||||
import type { MainFeedProps } from "./types.ts";
|
import type { MainFeedProps } from "./types.ts";
|
||||||
@@ -24,10 +26,10 @@ export function NewFeed(
|
|||||||
[dumps],
|
[dumps],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) return <p className="index-status">Loading…</p>;
|
if (loading) return <p className="index-status"><Trans>Loading…</Trans></p>;
|
||||||
if (error) return <ErrorCard title="Failed to load" message={error} />;
|
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||||
if (sorted.length === 0) {
|
if (sorted.length === 0) {
|
||||||
return <p className="index-status">No dumps yet. Be the first!</p>;
|
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,9 +49,9 @@ export function NewFeed(
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
{loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||||
{!hasMore && sorted.length > 0 && (
|
{!hasMore && sorted.length > 0 && (
|
||||||
<p className="feed-end">You've reached the end.</p>
|
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
44
src/utils/waveform.ts
Normal file
44
src/utils/waveform.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export const NUM_BARS = 150;
|
||||||
|
export const BAR_W = 3;
|
||||||
|
export const BAR_GAP = 1;
|
||||||
|
export const WAVEFORM_H = 48;
|
||||||
|
export const VIEWBOX_W = NUM_BARS * (BAR_W + BAR_GAP); // 600
|
||||||
|
|
||||||
|
// Module-level cache: survives StrictMode double-effect and re-renders
|
||||||
|
const peaksCache = new Map<string, Float32Array>();
|
||||||
|
|
||||||
|
export async function extractPeaks(
|
||||||
|
url: string,
|
||||||
|
n: number,
|
||||||
|
): Promise<Float32Array> {
|
||||||
|
if (peaksCache.has(url)) return peaksCache.get(url)!;
|
||||||
|
|
||||||
|
// /api/files is public (no auth middleware). Plain fetch avoids the CORS
|
||||||
|
// preflight that credentials/Authorization would trigger — the server's
|
||||||
|
// oakCors config doesn't set Allow-Credentials so credentialed fetches
|
||||||
|
// are blocked, while <audio> bypasses CORS entirely.
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
|
||||||
|
const actx = new AudioContext();
|
||||||
|
try {
|
||||||
|
const decoded = await actx.decodeAudioData(buf);
|
||||||
|
const data = decoded.getChannelData(0);
|
||||||
|
const blockSize = Math.floor(data.length / n);
|
||||||
|
const peaks = new Float32Array(n);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
let peak = 0;
|
||||||
|
const offset = i * blockSize;
|
||||||
|
for (let j = 0; j < blockSize; j++) {
|
||||||
|
const abs = Math.abs(data[offset + j]);
|
||||||
|
if (abs > peak) peak = abs;
|
||||||
|
}
|
||||||
|
peaks[i] = peak;
|
||||||
|
}
|
||||||
|
peaksCache.set(url, peaks);
|
||||||
|
return peaks;
|
||||||
|
} finally {
|
||||||
|
actx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import { lingui } from "@lingui/vite-plugin";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
@@ -8,8 +9,10 @@ export default defineConfig({
|
|||||||
ignored: ["**/api/**"],
|
ignored: ["**/api/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
optimizeDeps: {
|
lingui(),
|
||||||
include: ["react/jsx-runtime"],
|
react({
|
||||||
},
|
plugins: [["@lingui/swc-plugin", {}]],
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user