v3: added localization, use global player for uploaded audio/video files

This commit is contained in:
khannurien
2026-04-03 15:29:33 +00:00
parent 378b3ffa46
commit 0ce80398a4
64 changed files with 4248 additions and 941 deletions

View File

@@ -511,6 +511,85 @@
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 {
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 {
flex: 1 1 100px;
max-width: 120px;
@@ -824,7 +939,8 @@
.global-player--reduced .global-player-body {
grid-template-rows: 0fr;
}
.global-player-iframe-wrap {
.global-player-iframe-wrap,
.global-player-media-wrap {
overflow: hidden;
min-height: 0;
border-radius: 0 0 8px 8px;
@@ -1567,10 +1683,10 @@ body.has-player .fab-new {
margin: 1rem auto 0 auto;
max-width: 860px;
padding: 0.75rem 1rem;
border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent);
border: none;
border-radius: 10px;
background: color-mix(in srgb, var(--color-danger-bg) 92%, white 8%);
color: var(--color-text);
background: var(--color-danger-bg);
color: var(--color-on-accent);
line-height: 1.5;
overflow-wrap: anywhere;
word-break: break-word;
@@ -2256,6 +2372,22 @@ body.has-player .fab-new {
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 ── */
.dump-card-body,
.playlist-card-body {
@@ -2494,6 +2626,8 @@ body.has-player .fab-new {
.modal-body {
padding: 1rem 1.25rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;

View File

@@ -19,65 +19,57 @@ import { AuthProvider } from "./contexts/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
import { WSProvider } from "./contexts/WSProvider.tsx";
import { FollowProvider } from "./contexts/FollowProvider.tsx";
import { useAuth } from "./hooks/useAuth.ts";
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
import "./App.css";
function AppRoutes() {
const { token, user, logout } = useAuth();
return (
<WSProvider token={token} userId={user?.id ?? null} onForceLogout={logout}>
<FollowProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/dumps/:selectedDump" element={<Dump />} />
<Route
path="/dumps/:selectedDump/edit"
element={
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>
}
/>
<Route
path="/register"
element={
<RestrictedGuest>
<UserRegister />
</RestrictedGuest>
}
/>
<Route
path="/login"
element={
<RestrictedGuest>
<UserLogin />
</RestrictedGuest>
}
/>
<Route path="/users/:username" element={<UserPublicProfile />} />
<Route path="/users/:username/dumps" element={<UserDumps />} />
<Route path="/users/:username/upvoted" element={<UserUpvoted />} />
<Route
path="/users/:username/playlists"
element={<UserPlaylists />}
/>
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
<Route path="/search" element={<Search />} />
<Route
path="/notifications"
element={
<RestrictedLoggedIn>
<Notifications />
</RestrictedLoggedIn>
}
/>
</Routes>
</BrowserRouter>
</FollowProvider>
</WSProvider>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/dumps/:selectedDump" element={<Dump />} />
<Route
path="/dumps/:selectedDump/edit"
element={
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>
}
/>
<Route
path="/register"
element={
<RestrictedGuest>
<UserRegister />
</RestrictedGuest>
}
/>
<Route
path="/login"
element={
<RestrictedGuest>
<UserLogin />
</RestrictedGuest>
}
/>
<Route path="/users/:username" element={<UserPublicProfile />} />
<Route path="/users/:username/dumps" element={<UserDumps />} />
<Route path="/users/:username/upvoted" element={<UserUpvoted />} />
<Route
path="/users/:username/playlists"
element={<UserPlaylists />}
/>
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
<Route path="/search" element={<Search />} />
<Route
path="/notifications"
element={
<RestrictedLoggedIn>
<Notifications />
</RestrictedLoggedIn>
}
/>
</Routes>
);
}
@@ -85,7 +77,13 @@ function App() {
return (
<AuthProvider>
<PlayerProvider>
<AppRoutes />
<WSProvider>
<FollowProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</FollowProvider>
</WSProvider>
<GlobalPlayer />
</PlayerProvider>
</AuthProvider>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { t } from "@lingui/core/macro";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
@@ -32,7 +33,7 @@ export function AddToPlaylistModal(
})
.catch(() => {})
.finally(() => setLoading(false));
}, [dumpId]);
}, [dumpId, authFetch]);
const toggleMembership = async (membership: PlaylistMembership) => {
const { playlist, hasDump } = membership;
@@ -60,7 +61,7 @@ export function AddToPlaylistModal(
};
return (
<Modal title="Add to playlist" onClose={onClose}>
<Modal title={t`Add to playlist`} onClose={onClose}>
<PlaylistMembershipPanel
dumpId={dumpId}
memberships={memberships}

View File

@@ -1,5 +1,7 @@
import { type ReactNode, useState } from "react";
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 { useWS } from "../hooks/useWS.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
@@ -41,7 +43,7 @@ export function AppHeader(
to={`/users/${user.username}/playlists`}
className="app-header-user"
>
Playlists
<Trans>Playlists</Trans>
</Link>
</div>
@@ -56,16 +58,16 @@ export function AppHeader(
className="btn-primary"
onClick={() => setCreateModalOpen(true)}
disabled={disableNew}
title={disableNew ? "Server unreachable" : undefined}
title={disableNew ? t`Server unreachable` : undefined}
>
+ New
<Trans>+ New</Trans>
</button>
</>
)
: (
<>
<button type="button" onClick={() => navigate("/login")}>
Log in
<Trans>Log in</Trans>
</button>
</>
)}
@@ -74,7 +76,8 @@ export function AppHeader(
{wsStatus === "disconnected" && wsErrorMessage && (
<div className="app-header-status" role="alert">
<strong>Live updates unavailable.</strong> {wsErrorMessage}
<strong><Trans>Live updates unavailable.</Trans></strong>{" "}
{wsErrorMessage}
</div>
)}

View File

@@ -1,5 +1,7 @@
import React, { useMemo, useRef, useState } from "react";
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 type {
Comment,
@@ -103,7 +105,7 @@ function CommentNode({
setReplyError(data.error.message);
}
} catch {
setReplyError("Could not reach the server. Please try again.");
setReplyError(t`Could not reach the server. Please try again.`);
} finally {
setSubmitting(false);
}
@@ -142,7 +144,7 @@ function CommentNode({
setEditError(data.error.message);
}
} catch {
setEditError("Could not reach the server. Please try again.");
setEditError(t`Could not reach the server. Please try again.`);
} finally {
setEditSubmitting(false);
}
@@ -164,7 +166,9 @@ function CommentNode({
/>
</div>
<div className="comment-content">
<p className="comment-deleted-placeholder">[deleted]</p>
<p className="comment-deleted-placeholder">
<Trans>[deleted]</Trans>
</p>
</div>
</div>
{children.length > 0 && (
@@ -222,9 +226,9 @@ function CommentNode({
</Tooltip>
</Link>
{comment.updatedAt && (
<Tooltip text={`Edited ${comment.updatedAt.toLocaleString()}`}>
<Tooltip text={t`Edited ${comment.updatedAt.toLocaleString()}`}>
<span className="comment-edited">
edited {relativeTime(comment.updatedAt)}
<Trans>edited {relativeTime(comment.updatedAt)}</Trans>
</span>
</Tooltip>
)}
@@ -242,7 +246,7 @@ function CommentNode({
rows={1}
/>
{editError && (
<ErrorCard title="Failed to save edit" message={editError} />
<ErrorCard title={t`Failed to save edit`} message={editError} />
)}
<div className="comment-form-actions">
<button
@@ -250,7 +254,7 @@ function CommentNode({
className="comment-submit-btn"
disabled={editSubmitting || !editBody.trim()}
>
{editSubmitting ? "Saving…" : "Save"}
{editSubmitting ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
@@ -261,7 +265,7 @@ function CommentNode({
setEditError(null);
}}
>
Cancel
<Trans>Cancel</Trans>
</button>
</div>
</form>
@@ -277,7 +281,7 @@ function CommentNode({
setTimeout(() => replyEditorRef.current?.focus(), 0);
}}
>
Reply
<Trans>Reply</Trans>
</button>
)}
{canEdit && !editOpen && (
@@ -290,7 +294,7 @@ function CommentNode({
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
>
Edit
<Trans>Edit</Trans>
</button>
)}
{canDelete && !editOpen && (
@@ -299,13 +303,13 @@ function CommentNode({
className="comment-action-btn comment-delete-btn"
onClick={() => setConfirmDelete(true)}
>
Delete
<Trans>Delete</Trans>
</button>
)}
{confirmDelete && (
<ConfirmModal
message="Delete this comment?"
confirmLabel="Delete"
message={t`Delete this comment?`}
confirmLabel={t`Delete`}
onConfirm={() => {
setConfirmDelete(false);
handleDelete();
@@ -322,12 +326,12 @@ function CommentNode({
value={replyBody}
onChange={setReplyBody}
onSubmit={handleReply}
placeholder="Write a reply…"
placeholder={t`Write a reply…`}
autoResize
rows={1}
/>
{replyError && (
<ErrorCard title="Failed to post reply" message={replyError} />
<ErrorCard title={t`Failed to post reply`} message={replyError} />
)}
<div className="comment-form-actions">
<button
@@ -335,7 +339,7 @@ function CommentNode({
className="comment-submit-btn"
disabled={submitting || !replyBody.trim()}
>
{submitting ? "Posting…" : "Post reply"}
{submitting ? <Trans>Posting</Trans> : <Trans>Post reply</Trans>}
</button>
<button
type="button"
@@ -346,7 +350,7 @@ function CommentNode({
setReplyError(null);
}}
>
Cancel
<Trans>Cancel</Trans>
</button>
</div>
</form>
@@ -418,19 +422,18 @@ export function CommentThread({
setTopLevelError(data.error.message);
}
} catch {
setTopLevelError("Could not reach the server. Please try again.");
setTopLevelError(t`Could not reach the server. Please try again.`);
} finally {
setSubmitting(false);
}
}
const visibleCount = comments.filter((c) => !c.deleted).length;
return (
<section className="comment-section">
<h2 className="comment-section-title">
{(() => {
const n = comments.filter((c) => !c.deleted).length;
return n === 1 ? "1 comment" : `${n} comments`;
})()}
<Plural value={visibleCount} one="# comment" other="# comments" />
</h2>
{currentUser && (
@@ -450,13 +453,13 @@ export function CommentThread({
value={topLevelBody}
onChange={setTopLevelBody}
onSubmit={handleTopLevelSubmit}
placeholder="Add a comment…"
placeholder={t`Add a comment…`}
autoResize
rows={1}
/>
{topLevelError && (
<ErrorCard
title="Failed to post comment"
title={t`Failed to post comment`}
message={topLevelError}
/>
)}
@@ -466,7 +469,7 @@ export function CommentThread({
className="comment-submit-btn"
disabled={submitting || !topLevelBody.trim()}
>
{submitting ? "Posting…" : "Post comment"}
{submitting ? <Trans>Posting</Trans> : <Trans>Post comment</Trans>}
</button>
{topLevelBody.trim() && (
<button
@@ -477,7 +480,7 @@ export function CommentThread({
setTopLevelError(null);
}}
>
Cancel
<Trans>Cancel</Trans>
</button>
)}
</div>

View File

@@ -1,5 +1,7 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
interface ConfirmModalProps {
message: string;
@@ -9,8 +11,10 @@ interface ConfirmModalProps {
}
export function ConfirmModal(
{ message, confirmLabel = "Delete", onConfirm, onCancel }: ConfirmModalProps,
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
) {
const label = confirmLabel ?? t`Delete`;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
@@ -24,9 +28,11 @@ export function ConfirmModal(
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<p className="confirm-modal-message">{message}</p>
<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}>
{confirmLabel}
{label}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { Link, useNavigate } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { dumpUrl } from "../utils/urls.ts";
@@ -78,12 +79,17 @@ export function DumpCard(
</Tooltip>
{dump.commentCount > 0 && (
<span className="dump-card-comment-count">
{dump.commentCount}{" "}
{dump.commentCount === 1 ? "comment" : "comments"}
<Plural
value={dump.commentCount}
one="# comment"
other="# comments"
/>
</span>
)}
{dump.isPrivate && isOwner && (
<span className="dump-card-private-badge">private</span>
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
</div>

View File

@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
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 type {
@@ -26,6 +28,13 @@ import { TextEditor } from "./TextEditor.tsx";
import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
function normalizeUrl(input: string): string {
const s = input.trim();
if (!s || /^https?:\/\//i.test(s)) return s;
if (s.startsWith("//")) return `https:${s}`;
return `https://${s}`;
}
import { MAX_FILE_SIZE } from "../config/upload.ts";
type Mode = "url" | "file";
@@ -42,11 +51,16 @@ type UrlPreview =
| { status: "done"; richContent: RichContent | null };
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;
useEffect(() => () => URL.revokeObjectURL(src), [src]);
if (mime.startsWith("image/")) {
return <img src={src} alt={file.name} className="local-preview-image" />;
}
@@ -92,7 +106,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
let trimmed: string;
try {
const u = new URL(url.trim());
const u = new URL(normalizeUrl(url));
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
trimmed = u.toString();
} catch {
@@ -137,11 +151,11 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
if (tag === "INPUT" || tag === "TEXTAREA") return;
const text = e.clipboardData?.getData("text") ?? "";
try {
const u = new URL(text.trim());
const u = new URL(normalizeUrl(text));
if (u.protocol === "http:" || u.protocol === "https:") {
setMode("url");
setFile(null);
setUrl(text.trim());
setUrl(u.toString());
setSubmitState({ status: "idle" });
}
} catch { /* not a URL */ }
@@ -158,12 +172,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
let res: Response;
if (mode === "url") {
if (!url.trim()) {
setSubmitState({ status: "error", error: "URL is required." });
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) {
setSubmitState({ status: "error", error: t`URL is required.` });
return;
}
setUrl(normalizedUrl);
const body: CreateUrlDumpRequest = {
url: url.trim(),
url: normalizedUrl,
comment: comment.trim() || undefined,
isPrivate,
};
@@ -173,13 +189,16 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
});
} else {
if (!file) {
setSubmitState({ status: "error", error: "Please select a file." });
setSubmitState({
status: "error",
error: t`Please select a file.`,
});
return;
}
if (file.size > MAX_FILE_SIZE) {
setSubmitState({
status: "error",
error: "File too large (max 50 MB).",
error: t`File too large (max 50 MB).`,
});
return;
}
@@ -254,7 +273,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
return (
<Modal
title={phase === "create" ? "New dump" : "Add to playlist"}
title={phase === "create" ? t`New dump` : t`Add to playlist`}
onClose={onClose}
wide
>
@@ -285,14 +304,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
}}
disabled={submitting}
>
📎 File
📎 <Trans>File</Trans>
</button>
</div>
<form onSubmit={handleSubmit} className="dump-form">
{submitState.status === "error" && (
<ErrorCard
title="Failed to post"
title={t`Failed to post`}
message={submitState.error}
/>
)}
@@ -301,12 +320,13 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
? (
<>
<div className="form-group">
<label htmlFor="dc-url">URL</label>
<label htmlFor="dc-url"><Trans>URL</Trans></label>
<input
id="dc-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={(e) => setUrl(normalizeUrl(e.target.value))}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
@@ -325,7 +345,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">Fetching preview</p>
<p className="preview-loading"><Trans>Fetching preview</Trans></p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
@@ -348,14 +368,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
<div className="form-group">
<label htmlFor="dc-comment">
Why are you dumping this?
<Trans>Why are you dumping this?</Trans>
</label>
<TextEditor
id="dc-comment"
value={comment}
onChange={setComment}
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}
/>
</div>
@@ -367,7 +387,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
disabled={submitting}
onClick={() => setIsPrivate(false)}
>
Public
<Trans>Public</Trans>
</button>
<button
type="button"
@@ -375,7 +395,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
disabled={submitting}
onClick={() => setIsPrivate(true)}
>
Private
<Trans>Private</Trans>
</button>
</div>
@@ -386,7 +406,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
className="form-cancel"
onClick={onClose}
>
Cancel
<Trans>Cancel</Trans>
</button>
<button
type="submit"
@@ -394,8 +414,10 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
disabled={submitting}
>
{submitting
? (mode === "url" ? "Fetching…" : "Uploading…")
: "Dump it"}
? (mode === "url"
? <Trans>Fetching</Trans>
: <Trans>Uploading</Trans>)
: <Trans>Dump it</Trans>}
</button>
</div>
</div>
@@ -406,9 +428,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
<>
{createdDump && (
<p className="dump-create-success">
Dumped!{" "}
<Trans>Dumped!</Trans>{" "}
<Link to={dumpUrl(createdDump)} onClick={onClose}>
View dump
<Trans>View dump </Trans>
</Link>
</p>
)}
@@ -429,7 +451,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
className="btn-primary"
onClick={onClose}
>
Done
<Trans>Done</Trans>
</button>
</div>
</div>

View File

@@ -1,13 +1,7 @@
import { useLocation, useNavigate } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts";
export type FeedTab = "hot" | "new" | "journal" | "followed";
export const VALID_TABS = new Set<string>([
"hot",
"new",
"journal",
"followed",
]);
import { type FeedTab, VALID_TABS } from "../config/feedTabs.ts";
export function FeedTabBar() {
const location = useLocation();
@@ -28,21 +22,21 @@ export function FeedTabBar() {
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
onClick={() => setTab("hot")}
>
Hot
<Trans>Hot</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
onClick={() => setTab("new")}
>
New
<Trans>New</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
onClick={() => setTab("journal")}
>
Journal
<Trans>Journal</Trans>
</button>
{user && (
<button
@@ -50,7 +44,7 @@ export function FeedTabBar() {
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
Followed
<Trans>Followed</Trans>
</button>
)}
</div>

View File

@@ -1,4 +1,6 @@
import { useCallback, useRef, useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { formatBytes } from "../utils/format.ts";
function fileIcon(mime: string): string {
@@ -22,10 +24,12 @@ export function FileDropZone({
file,
onChange,
disabled,
label = "File",
hint = "Drop a file here",
label,
hint,
showLimit = true,
}: FileDropZoneProps) {
const resolvedLabel = label ?? t`File`;
const resolvedHint = hint ?? t`Drop a file here`;
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
@@ -69,7 +73,7 @@ export function FileDropZone({
return (
<div className="fdz-wrapper">
{label && <span className="fdz-label">{label}</span>}
{resolvedLabel && <span className="fdz-label">{resolvedLabel}</span>}
<div
className={`fdz${dragging ? " fdz--drag" : ""}${
disabled ? " fdz--disabled" : ""
@@ -108,7 +112,7 @@ export function FileDropZone({
type="button"
className="fdz__clear"
onClick={handleClear}
aria-label="Remove file"
aria-label={t`Remove file`}
>
</button>
@@ -130,11 +134,11 @@ export function FileDropZone({
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p className="fdz__hint">{hint}</p>
<p className="fdz__hint">{resolvedHint}</p>
<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>
{showLimit && <p className="fdz__limit">Max 50 MB</p>}
{showLimit && <p className="fdz__limit"><Trans>Max 50 MB</Trans></p>}
</div>
)}
</div>

View File

@@ -1,11 +1,119 @@
import { useContext, useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { formatBytes } from "../utils/format.ts";
import { MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts";
import {
BAR_GAP,
BAR_W,
extractPeaks,
NUM_BARS,
VIEWBOX_W,
WAVEFORM_H,
} from "../utils/waveform.ts";
interface FilePreviewProps {
dump: Dump;
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 {
@@ -17,10 +125,13 @@ function mimeIcon(mime: string): string {
}
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 mime = dump.fileMime ?? "";
const isMedia = mime.startsWith("video/") || mime.startsWith("audio/");
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
if (compact) {
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>;
}
@@ -45,10 +195,37 @@ export default function FilePreview(
}
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} />;
}
if (mime.startsWith("audio/")) {
if (useGlobal) {
return <AudioFilePreview fileUrl={fileUrl} mime={mime} dump={dump} />;
}
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
}

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { useFollows } from "../hooks/useFollows.ts";
@@ -29,10 +31,10 @@ export function FollowUserButton(
onClick={() =>
isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
aria-label={isFollowing
? `Unfollow ${targetUsername}`
: `Follow ${targetUsername}`}
? t`Unfollow ${targetUsername}`
: t`Follow ${targetUsername}`}
>
{isFollowing ? "Following" : "Follow"}
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
</button>
);
}
@@ -57,9 +59,9 @@ export function FollowPlaylistButton(
isFollowing
? unfollowPlaylist(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>
);
}

View File

@@ -1,10 +1,23 @@
import { useContext, useEffect, useRef, useState } from "react";
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() {
const { current, stop } = useContext(PlayerContext);
const { current, stop, seekRef, toggleRef, onPlayStateChange, onTimeUpdate } =
useContext(PlayerContext);
const ref = useRef<HTMLDivElement>(null);
const [reduced, setReduced] = useState(false);
const [prevKey, setPrevKey] = useState(itemKey(current));
if (prevKey !== itemKey(current)) {
setPrevKey(itemKey(current));
if (current) setReduced(false);
}
useEffect(() => {
if (!current) {
@@ -33,23 +46,21 @@ export function GlobalPlayer() {
};
}, [current]);
useEffect(() => {
if (current) setReduced(false);
}, [current?.embedUrl]);
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 (
<div
className={`global-player global-player--${current.type}${
reduced ? " global-player--reduced" : ""
}`}
className={`global-player global-player--${typeClass}${reduced ? " global-player--reduced" : ""}`}
ref={ref}
>
<div className="global-player-header">
<span className="global-player-title">
{current.title ?? current.embedUrl}
</span>
<span className="global-player-title">{title}</span>
<button
type="button"
className="btn btn--ghost"
@@ -62,14 +73,32 @@ export function GlobalPlayer() {
</button>
</div>
<div className="global-player-body">
<div className="global-player-iframe-wrap">
<iframe
src={current.embedUrl}
className={`global-player-iframe--${current.type}`}
allow="autoplay; encrypted-media"
allowFullScreen
/>
</div>
{current.kind === "embed"
? (
<div className="global-player-iframe-wrap">
<iframe
src={current.embedUrl}
className={`global-player-iframe--${current.type}`}
allow="autoplay; encrypted-media"
allowFullScreen
/>
</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>
);

View File

@@ -1,4 +1,12 @@
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 {
if (!isFinite(s)) return "0:00";
@@ -38,15 +46,91 @@ const IconFullscreen = () => (
</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;
interface MediaPlayerProps {
src: string;
kind: "audio" | "video";
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 [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
@@ -57,13 +141,93 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
const [controlsVisible, setControlsVisible] = useState(true);
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(() => {
const a = mediaRef.current!;
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("durationchange", onDuration);
a.addEventListener("ended", onEnded);
@@ -74,30 +238,18 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
};
}, [dragging]);
// Stop playback on unmount; the browser aborts network requests when the element leaves the DOM.
useEffect(() => {
const a = mediaRef.current!;
return () => {
a.pause();
};
}, []);
// Schedule controls hide when playing; controls are always visible when paused (derived below)
// Hide video controls after inactivity
useEffect(() => {
if (kind !== "video") return;
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(
() => setControlsVisible(false),
HIDE_DELAY,
);
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
}
return () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
};
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
}, [playing, kind]);
// Controls are always visible when paused or for audio; otherwise follow controlsVisible state
// ── Render helpers ────────────────────────────────────────────────────────────
const showingControls = kind !== "video" || !playing || controlsVisible;
const showControlsTemporarily = () => {
@@ -105,26 +257,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
setControlsVisible(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(
() => setControlsVisible(false),
HIDE_DELAY,
);
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
}
};
const toggle = () => {
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 seek = (e: React.ChangeEvent<HTMLInputElement>) => seekTo(Number(e.target.value));
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
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 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>
const track = kind === "audio"
? <Waveform src={src} current={current} duration={duration} onSeek={seekTo} />
: (
<div className="audio-player-track">
<div
className="audio-player-fill"
style={{ width: `${progress * 100}%` }}
/>
<div className="audio-player-fill" style={{ width: `${progress * 100}%` }} />
<input
type="range"
className="audio-player-range"
@@ -179,6 +303,22 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
aria-label="Seek"
/>
</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>
@@ -225,9 +365,7 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
if (kind === "video") {
return (
<div
className={`video-player${
showingControls ? " video-player--controls-visible" : ""
}`}
className={`video-player${showingControls ? " video-player--controls-visible" : ""}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => playing && setControlsVisible(false)}
>

View File

@@ -1,5 +1,6 @@
import { type ReactNode, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
interface ModalProps {
title: string;
@@ -41,7 +42,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
aria-label={t`Close`}
>
</button>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { useWS } from "../hooks/useWS.ts";
export function NotificationBell() {
@@ -18,12 +19,15 @@ export function NotificationBell() {
if (animatingRef.current) return;
animatingRef.current = true;
setRinging(true);
const t = setTimeout(() => {
const tStart = setTimeout(() => setRinging(true), 0);
const tEnd = setTimeout(() => {
setRinging(false);
animatingRef.current = false;
}, 700);
return () => clearTimeout(t);
return () => {
clearTimeout(tStart);
clearTimeout(tEnd);
};
}, [lastNotification]);
return (
@@ -33,11 +37,9 @@ export function NotificationBell() {
ringing ? " notification-bell--ringing" : ""
}`}
onClick={() => navigate("/notifications")}
aria-label={`Notifications${
unreadNotificationCount > 0
? ` (${unreadNotificationCount} unread)`
: ""
}`}
aria-label={unreadNotificationCount > 0
? t`Notifications (${unreadNotificationCount} unread)`
: t`Notifications`}
>
<span className="notification-bell-icon">🔔</span>
{unreadNotificationCount > 0 && (

View File

@@ -1,18 +1,20 @@
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { PageShell } from "./PageShell.tsx";
import { ErrorCard } from "./ErrorCard.tsx";
export function PageError(
{ title = "Something went wrong", message, actions }: {
{ title, message, actions }: {
title?: string;
message: string;
actions?: ReactNode;
},
) {
const resolvedTitle = title ?? t`Something went wrong`;
return (
<PageShell>
<div className="page-error-wrap">
<ErrorCard title={title} message={message} actions={actions} />
<ErrorCard title={resolvedTitle} message={message} actions={actions} />
</div>
</PageShell>
);

View File

@@ -1,4 +1,6 @@
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 type { Playlist } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
@@ -66,7 +68,7 @@ export function PlaylistCard(
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
</span>
{playlist.ownerUsername && !isOwner && (
<Link
@@ -79,8 +81,11 @@ export function PlaylistCard(
)}
{playlist.dumpCount !== undefined && (
<span className="playlist-card-count">
{playlist.dumpCount}{" "}
{playlist.dumpCount === 1 ? "dump" : "dumps"}
<Plural
value={playlist.dumpCount}
one="# dump"
other="# dumps"
/>
</span>
)}
<Tooltip text={playlist.createdAt.toLocaleString()}>
@@ -99,7 +104,7 @@ export function PlaylistCard(
e.stopPropagation();
onDelete();
}}
aria-label="Delete playlist"
aria-label={t`Delete playlist`}
>
</button>

View File

@@ -1,4 +1,6 @@
import { useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
@@ -54,7 +56,7 @@ export function PlaylistCreateForm(
}
onCreated(playlist);
} catch {
setError("Failed to create playlist");
setError(t`Failed to create playlist`);
} finally {
setSubmitting(false);
}
@@ -64,14 +66,14 @@ export function PlaylistCreateForm(
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Title"
placeholder={t`Title`}
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
required
/>
<TextEditor
placeholder="Description (optional)"
placeholder={t`Description (optional)`}
value={description}
onChange={setDescription}
rows={3}
@@ -82,17 +84,17 @@ export function PlaylistCreateForm(
className={isPublic ? "active" : ""}
onClick={() => setIsPublic(true)}
>
Public
<Trans>Public</Trans>
</button>
<button
type="button"
className={!isPublic ? "active" : ""}
onClick={() => setIsPublic(false)}
>
Private
<Trans>Private</Trans>
</button>
</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-right">
<button
@@ -100,14 +102,18 @@ export function PlaylistCreateForm(
className="form-cancel"
onClick={onCancel}
>
Cancel
<Trans>Cancel</Trans>
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting}
>
{submitting ? "Creating…" : dumpId ? "Create & Add" : "Create"}
{submitting
? <Trans>Creating</Trans>
: dumpId
? <Trans>Create & Add</Trans>
: <Trans>Create</Trans>}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { Trans } from "@lingui/react/macro";
import type { PlaylistMembership } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
@@ -22,9 +23,9 @@ export function PlaylistMembershipPanel({
return (
<>
{loading
? <p className="page-loading">Loading</p>
? <p className="page-loading"><Trans>Loading</Trans></p>
: 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">
{memberships.map((m) => (
@@ -43,7 +44,7 @@ export function PlaylistMembershipPanel({
</span>
{!m.playlist.isPublic && (
<span className="playlist-badge playlist-badge--private">
private
<Trans>private</Trans>
</span>
)}
</li>
@@ -68,7 +69,7 @@ export function PlaylistMembershipPanel({
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
+ New playlist
<Trans>+ New playlist</Trans>
</button>
)}
</>

View File

@@ -47,6 +47,7 @@ export default function RichContentCard(
className="rich-content-thumbnail-btn"
onClick={() =>
play({
kind: "embed",
embedUrl: richContent.embedUrl!,
title: richContent.title,
type: richContent.type,

View File

@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
interface SearchBarProps {
collapsible?: boolean;
@@ -15,7 +16,7 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
useEffect(() => {
if (collapsible && expanded) inputRef.current?.focus();
}, [expanded]);
}, [expanded, collapsible]);
function handleIconClick() {
if (!collapsible) return;
@@ -57,17 +58,17 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
ref={inputRef}
type="search"
className="search-bar-input"
placeholder="Search dumps, users, playlists…"
placeholder={t`Search dumps, users, playlists…`}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
aria-label="Search"
aria-label={t`Search`}
tabIndex={expanded ? 0 : -1}
/>
<button
type={expanded && !collapsible ? "submit" : "button"}
className="search-bar-btn"
aria-label={expanded ? "Submit search" : "Open search"}
aria-label={expanded ? t`Submit search` : t`Open search`}
onClick={collapsible ? handleIconClick : undefined}
>
🔍

View File

@@ -7,6 +7,7 @@ import {
useState,
} from "react";
import { EmojiPicker } from "frimousse";
import { Trans } from "@lingui/react/macro";
import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.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
tabIndex={-1}
>
<EmojiPicker.Loading>Loading</EmojiPicker.Loading>
<EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>
<EmojiPicker.Loading><Trans>Loading</Trans></EmojiPicker.Loading>
<EmojiPicker.Empty><Trans>No emoji found.</Trans></EmojiPicker.Empty>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { Avatar } from "./Avatar.tsx";
import type { User } from "../model.ts";
@@ -32,7 +34,7 @@ export function UserMenu({ user }: { user: User }) {
className="user-menu-trigger"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label="User menu"
aria-label={t`User menu`}
>
<Avatar
userId={user.id}
@@ -57,7 +59,7 @@ export function UserMenu({ user }: { user: User }) {
role="menuitem"
onClick={() => setOpen(false)}
>
Playlists
<Trans>Playlists</Trans>
</Link>
</div>
)}

3
src/config/feedTabs.ts Normal file
View 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);

View File

@@ -1,19 +1,43 @@
import { createContext } from "react";
export interface PlayerItem {
embedUrl: string;
title?: string;
type: string;
}
export type PlayerItem =
| { kind: "embed"; embedUrl: string; title?: string; type: string }
| { kind: "file"; fileUrl: string; mimeType: string; title?: string };
export interface PlayerContextValue {
// Playback state — readable by any consumer
current: PlayerItem | null;
playing: boolean;
currentTime: number;
duration: number;
// Control — callable by any consumer
play(item: PlayerItem): 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>({
current: null,
playing: false,
currentTime: 0,
duration: 0,
play: () => {},
stop: () => {},
seekTo: () => {},
togglePlay: () => {},
seekRef: { current: null },
toggleRef: { current: null },
onPlayStateChange: () => {},
onTimeUpdate: () => {},
});

View File

@@ -1,12 +1,80 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
export function PlayerProvider({ children }: { children: React.ReactNode }) {
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;
const stop = useCallback(() => setCurrent(null), []);
const value = useMemo(() => ({ current, play, stop }), [current, play, stop]);
// GlobalPlayer registers the active MediaPlayer's imperative handles here
const seekRef = useRef<((t: number) => void) | null>(null);
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 (
<PlayerContext.Provider value={value}>

View File

@@ -30,12 +30,11 @@ import {
deserializePlaylist,
deserializePublicUser,
} from "../model.ts";
import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts";
interface WSProviderProps {
children: ReactNode;
token: string | null;
userId: string | null;
onForceLogout?: () => void;
}
const MAX_BACKOFF = 30_000;
@@ -61,13 +60,23 @@ function parseWSMessage(data: string): IncomingWSMessage | null {
}
}
export function WSProvider(
{ children, token, userId, onForceLogout }: WSProviderProps,
) {
export function WSProvider({ children }: WSProviderProps) {
const { token, user, logout } = useAuth();
const userId = user?.id ?? null;
const [wsStatus, setWSStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
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 [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
@@ -94,10 +103,14 @@ export function WSProvider(
const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes);
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(() => {
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
userIdRef.current = userId;
onForceLogoutRef.current = logout;
});
const socketRef = useRef<WebSocket | null>(null);
@@ -139,9 +152,6 @@ export function WSProvider(
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
let everConnected = false;
setWSStatus("connecting");
setWSErrorMessage(null);
function connect() {
if (closed) return;
@@ -155,7 +165,7 @@ export function WSProvider(
if (ws.readyState !== WebSocket.CONNECTING) return;
setWSStatus("disconnected");
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();
}, CONNECT_TIMEOUT);
@@ -327,7 +337,7 @@ export function WSProvider(
}
case "force_logout":
onForceLogout?.();
onForceLogoutRef.current();
break;
case "error":
@@ -346,8 +356,8 @@ export function WSProvider(
setWSStatus("disconnected");
setWSErrorMessage(
everConnected
? "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`Live updates are temporarily disconnected. Trying to reconnect`
: t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
);
reconnectTimer = setTimeout(() => {
backoff = Math.min(backoff * 2, MAX_BACKOFF);

View File

@@ -1,7 +1,7 @@
import { type RefObject, useCallback, useRef, useState } from "react";
// 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 {
open: boolean;

View File

@@ -63,6 +63,11 @@ export function useUserDumpFeed(
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
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[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) });
@@ -70,7 +75,6 @@ export function useUserDumpFeed(
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
const controller = new AbortController();
if (cached) {
@@ -126,7 +130,7 @@ export function useUserDumpFeed(
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username, endpoint]);
}, [username, endpoint, cached, token]);
const { onItemsAppended } = options ?? {};

20
src/i18n.ts Normal file
View 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

File diff suppressed because one or more lines are too long

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
View 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 132 characters and contain only letters, numbers, or underscores"
#~ msgstr "Username must be 132 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
View 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."

View File

@@ -1,12 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { I18nProvider } from "@lingui/react";
import App from "./App.tsx";
import { i18n, loadCatalog } from "./i18n.ts";
import "./index.css";
await loadCatalog();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<I18nProvider i18n={i18n}>
<App />
</I18nProvider>
</StrictMode>,
);

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
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 { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
@@ -105,7 +107,7 @@ export function Dump() {
}
})();
return () => controller.abort();
}, [selectedDump, preloaded]);
}, [selectedDump, preloaded, token]);
useEffect(() => {
if (!lastDumpEvent) return;
@@ -143,16 +145,14 @@ export function Dump() {
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.add("comment-node--highlight");
const t = setTimeout(
const timer = setTimeout(
() => el.classList.remove("comment-node--highlight"),
2000,
);
return () => clearTimeout(t);
return () => clearTimeout(timer);
}, [comments, location.hash]);
// 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;
useEffect(() => {
if (
@@ -190,7 +190,7 @@ export function Dump() {
if (dumpState.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading dump</p>
<p className="page-loading"><Trans>Loading dump</Trans></p>
</PageShell>
);
}
@@ -206,14 +206,14 @@ export function Dump() {
type="button"
onClick={() => globalThis.location.reload()}
>
Retry
<Trans>Retry</Trans>
</button>
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
Back to all dumps
<Trans> Back to all dumps</Trans>
</button>
</>
}
@@ -245,7 +245,7 @@ export function Dump() {
className="btn-add-playlist"
onClick={() => setPlaylistModalOpen(true)}
>
+ Playlist
<Trans>+ Playlist</Trans>
</button>
)}
<div className="dump-op">
@@ -271,14 +271,16 @@ export function Dump() {
</time>
</Tooltip>
{dump.updatedAt && (
<Tooltip text={`Edited ${dump.updatedAt.toLocaleString()}`}>
<Tooltip text={t`Edited ${dump.updatedAt.toLocaleString()}`}>
<span className="dump-edited-label">
edited {relativeTime(dump.updatedAt)}
<Trans>edited {relativeTime(dump.updatedAt)}</Trans>
</span>
</Tooltip>
)}
{dump.isPrivate && (
<span className="dump-card-private-badge">private</span>
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
</div>
@@ -291,7 +293,7 @@ export function Dump() {
{/* Main content */}
<div className="dump-rich-content">
{dump.kind === "file"
? <FilePreview dump={dump} />
? <FilePreview dump={dump} global />
: dump.richContent
? <RichContentCard richContent={dump.richContent} />
: (
@@ -308,8 +310,12 @@ export function Dump() {
{/* Actions */}
<div className="dump-actions">
{canEdit && <Link to={`${dumpUrl(dump)}/edit`}>Edit</Link>}
<Link to="/"> Back to all dumps</Link>
{canEdit && (
<Link to={`${dumpUrl(dump)}/edit`}>
<Trans>Edit</Trans>
</Link>
)}
<Link to="/"><Trans> Back to all dumps</Trans></Link>
</div>
{/* Comments */}

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
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 type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
@@ -60,7 +62,7 @@ export function DumpEdit() {
setState({ status: "error", error: friendlyFetchError(err) });
}
})();
}, [selectedDump]);
}, [selectedDump, token]);
const handleSave = async () => {
if (state.status !== "loaded") return;
@@ -138,7 +140,7 @@ export function DumpEdit() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading dump</p>
<p className="page-loading"><Trans>Loading dump</Trans></p>
</PageShell>
);
}
@@ -154,14 +156,14 @@ export function DumpEdit() {
type="button"
onClick={() => globalThis.location.reload()}
>
Retry
<Trans>Retry</Trans>
</button>
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
Back to all dumps
<Trans> Back to all dumps</Trans>
</button>
</>
}
@@ -175,7 +177,7 @@ export function DumpEdit() {
<PageShell>
<div className="form-page form-page--two-col">
<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>
</div>
@@ -201,7 +203,7 @@ export function DumpEdit() {
onClick={handleRefreshMetadata}
disabled={refreshing}
>
{refreshing ? "Refreshing…" : "Refresh metadata"}
{refreshing ? <Trans>Refreshing</Trans> : <Trans>Refresh metadata</Trans>}
</button>
)}
</div>
@@ -216,7 +218,7 @@ export function DumpEdit() {
{dump.kind === "url"
? (
<div className="form-group">
<label htmlFor="url">URL</label>
<label htmlFor="url"><Trans>URL</Trans></label>
<input
id="url"
type="url"
@@ -236,20 +238,22 @@ export function DumpEdit() {
<FileDropZone
file={newFile}
onChange={setNewFile}
label="Replace file"
hint="Drop a replacement here"
label={t`Replace file`}
hint={t`Drop a replacement here`}
showLimit={false}
/>
</div>
)}
<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
id="comment"
value={comment}
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}
/>
</div>
@@ -260,14 +264,14 @@ export function DumpEdit() {
className={!isPrivate ? "active" : ""}
onClick={() => setIsPrivate(false)}
>
Public
<Trans>Public</Trans>
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
onClick={() => setIsPrivate(true)}
>
Private
<Trans>Private</Trans>
</button>
</div>
@@ -277,21 +281,23 @@ export function DumpEdit() {
onClick={() => setConfirmDelete(true)}
className="btn-danger"
>
Delete dump
<Trans>Delete dump</Trans>
</button>
<div className="form-actions-right">
<Link to={dumpUrl(dump)} className="form-cancel">
Cancel
<Trans>Cancel</Trans>
</Link>
<button type="submit" className="btn-primary">Save</button>
<button type="submit" className="btn-primary">
<Trans>Save</Trans>
</button>
</div>
</div>
</form>
</div>
{confirmDelete && (
<ConfirmModal
message="Delete this dump? This cannot be undone."
confirmLabel="Delete dump"
message={t`Delete this dump? This cannot be undone.`}
confirmLabel={t`Delete dump`}
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>

View File

@@ -11,11 +11,8 @@ import { useLocation } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx";
import {
type FeedTab,
FeedTabBar,
VALID_TABS,
} from "../components/FeedTabBar.tsx";
import { FeedTabBar } from "../components/FeedTabBar.tsx";
import { type FeedTab, VALID_TABS } from "../config/feedTabs.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";

View File

@@ -1,5 +1,7 @@
import React, { useEffect, useState } from "react";
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 { useAuth } from "../hooks/useAuth.ts";
@@ -110,62 +112,62 @@ function notificationContent(n: Notification): React.ReactNode {
case "user_followed": {
const d = data as UserFollowedData;
return (
<>
<Trans>
<strong>{d.followerUsername}</strong>
{" started following you"}
</>
</Trans>
);
}
case "playlist_followed": {
const d = data as PlaylistFollowedData;
return (
<>
<Trans>
<strong>{d.followerUsername}</strong>
{" followed your playlist "}
<strong>{d.playlistTitle}</strong>
</>
</Trans>
);
}
case "user_dump_posted": {
const d = data as UserDumpPostedData;
return (
<>
<Trans>
<strong>{d.dumperUsername}</strong>
{" posted "}
<strong>{d.dumpTitle}</strong>
</>
</Trans>
);
}
case "playlist_dump_added": {
const d = data as PlaylistDumpAddedData;
return (
<>
<Trans>
<strong>{d.dumpTitle}</strong>
{" was added to "}
<strong>{d.playlistTitle}</strong>
</>
</Trans>
);
}
case "dump_upvoted": {
const d = data as DumpUpvotedData;
return (
<>
<Trans>
<strong>{d.voterUsername}</strong>
{" upvoted "}
<strong>{d.dumpTitle}</strong>
</>
</Trans>
);
}
case "user_mentioned": {
const d = data as UserMentionedData;
const where = d.contextTitle ||
(d.contextType === "comment" ? "a comment" : "a post");
(d.contextType === "comment" ? t`a comment` : t`a post`);
return (
<>
<Trans>
<strong>{d.mentionerUsername}</strong>
{" mentioned you in "}
<strong>{where}</strong>
</>
</Trans>
);
}
}
@@ -173,13 +175,13 @@ function notificationContent(n: Notification): React.ReactNode {
function timeAgo(date: Date): string {
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);
if (mins < 60) return `${mins}m ago`;
if (mins < 60) return t`${mins}m ago`;
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);
if (days < 7) return `${days}d ago`;
if (days < 7) return t`${days}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
@@ -215,8 +217,6 @@ export function Notifications() {
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
// 1. Fetch with original read state so unread items are highlighted
// 2. Only after displaying, mark all read on the server
authFetch(
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
)
@@ -231,7 +231,6 @@ export function Notifications() {
page: 1,
loadingMore: false,
});
// Mark read server-side after we've shown the unread state
return authFetch(`${API_URL}/api/notifications/read-all`, {
method: "POST",
});
@@ -251,17 +250,19 @@ export function Notifications() {
setState({ status: "error", error: err.message });
}
});
}, []);
}, [authFetch, clearUnreadNotifications]);
useEffect(() => {
if (!lastNotification) return;
const [prevLastNotification, setPrevLastNotification] = useState(
lastNotification,
);
if (prevLastNotification !== lastNotification && lastNotification !== null) {
setPrevLastNotification(lastNotification);
setState((s) => {
if (s.status !== "loaded") 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] };
});
}, [lastNotification]);
}
const loadMore = () => {
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
@@ -304,27 +305,31 @@ export function Notifications() {
<div className="notifications-header">
<h1 className="notifications-title">
<span className="notifications-title-bell">🔔</span>
Notifications
<Trans>Notifications</Trans>
</h1>
{state.status === "loaded" && totalUnread > 0 && (
<span className="notifications-unread-pill">
{totalUnread} new
{totalUnread} <Trans>new</Trans>
</span>
)}
</div>
{state.status === "loading" && <p className="page-loading">Loading</p>}
{state.status === "loading" && (
<p className="page-loading"><Trans>Loading</Trans></p>
)}
{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 && (
<div className="notifications-empty">
<span className="notifications-empty-icon">🔕</span>
<p>Nothing here yet.</p>
<p><Trans>Nothing here yet.</Trans></p>
<p className="notifications-empty-hint">
You'll be notified when someone follows your playlists, upvotes
your dumps, or posts new content.
<Trans>
You'll be notified when someone follows your playlists, upvotes
your dumps, or posts new content.
</Trans>
</p>
</div>
)}
@@ -332,7 +337,9 @@ export function Notifications() {
{state.status === "loaded" && state.items.length > 0 &&
groupByDate(state.items).map(({ label, items }) => (
<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">
{items.map((n) => (
<li
@@ -376,7 +383,7 @@ export function Notifications() {
onClick={loadMore}
disabled={state.loadingMore}
>
{state.loadingMore ? "Loading…" : "Load more"}
{state.loadingMore ? <Trans>Loading</Trans> : <Trans>Load more</Trans>}
</button>
)}
</div>

View File

@@ -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 { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type {
PlaylistWithDumps,
@@ -108,7 +116,7 @@ export function PlaylistDetail() {
const fetchAbortRef = useRef<AbortController | null>(null);
const fetchPlaylist = () => {
const fetchPlaylist = useCallback(() => {
if (!playlistId) return;
fetchAbortRef.current?.abort();
const controller = new AbortController();
@@ -152,12 +160,12 @@ export function PlaylistDetail() {
error: friendlyFetchError(err),
});
});
};
}, [playlistId, token]);
useEffect(() => {
fetchPlaylist();
return () => fetchAbortRef.current?.abort();
}, [playlistId]);
}, [fetchPlaylist]);
// Start the cooldown→dismissing→gone sequence for a dump being removed.
// After the sequence completes, the dump is removed from state.playlist.dumps.
@@ -337,7 +345,7 @@ export function PlaylistDetail() {
} else if (ev.type === "deleted") {
navigate("/");
}
}, [lastPlaylistEvent, playlistUUID]);
}, [lastPlaylistEvent, playlistUUID, navigate, token]);
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
useEffect(() => {
@@ -579,7 +587,7 @@ export function PlaylistDetail() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading playlist</p>
<p className="page-loading"><Trans>Loading playlist</Trans></p>
</PageShell>
);
}
@@ -594,7 +602,7 @@ export function PlaylistDetail() {
type="button"
onClick={() => navigate("/")}
>
Back
<Trans> Back</Trans>
</button>
}
/>
@@ -654,21 +662,21 @@ export function PlaylistDetail() {
disabled={editSaving}
onClick={handleEditSave}
>
{editSaving ? "Saving…" : "Save"}
{editSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
className="form-cancel"
onClick={() => setEditOpen(false)}
>
Cancel
<Trans>Cancel</Trans>
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
Delete
<Trans>Delete</Trans>
</button>
</div>
)
@@ -687,7 +695,7 @@ export function PlaylistDetail() {
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
<Trans>Edit</Trans>
</button>
)}
</div>
@@ -699,7 +707,7 @@ export function PlaylistDetail() {
className="playlist-edit-textarea"
value={editDescription}
onChange={setEditDescription}
placeholder="Description (optional)"
placeholder={t`Description (optional)`}
autoResize
rows={1}
/>
@@ -719,14 +727,14 @@ export function PlaylistDetail() {
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
Public
<Trans>Public</Trans>
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
Private
<Trans>Private</Trans>
</button>
</div>
)
@@ -737,7 +745,7 @@ export function PlaylistDetail() {
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
</span>
{playlist.ownerUsername && (
<Link
@@ -754,10 +762,10 @@ export function PlaylistDetail() {
</Tooltip>
{playlist.updatedAt && (
<Tooltip
text={`Edited ${playlist.updatedAt.toLocaleString()}`}
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
>
<span className="playlist-edited-label">
edited {relativeTime(playlist.updatedAt)}
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans>
</span>
</Tooltip>
)}
@@ -765,14 +773,14 @@ export function PlaylistDetail() {
)}
</div>
{editError && (
<ErrorCard title="Failed to save" message={editError} />
<ErrorCard title={t`Failed to save`} message={editError} />
)}
</div>
</div>
</div>
{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
className="playlist-dump-list"
@@ -827,7 +835,7 @@ export function PlaylistDetail() {
type="button"
className="playlist-remove-btn"
onClick={() => handleRemoveDump(dump.id)}
aria-label="Remove from playlist"
aria-label={t`Remove from playlist`}
>
</button>
@@ -837,9 +845,9 @@ export function PlaylistDetail() {
type="button"
className="playlist-cancel-btn"
onClick={() => handleCancelRemove(dump.id)}
aria-label="Cancel removal"
aria-label={t`Cancel removal`}
>
Undo
<Trans>Undo</Trans>
</button>
))}
</div>
@@ -849,8 +857,8 @@ export function PlaylistDetail() {
)}
{confirmDelete && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
message={t`Delete this playlist? This cannot be undone.`}
confirmLabel={t`Delete playlist`}
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 { SearchBar } from "../components/SearchBar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
@@ -151,10 +153,10 @@ export function Search() {
!state.dumps.loadingMore,
);
function setTab(t: Tab) {
function setTab(tab: Tab) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", t);
next.set("tab", tab);
return next;
}, { replace: true });
}
@@ -165,13 +167,13 @@ export function Search() {
? state.playlists.length
: null;
function tabLabel(t: Tab, count: number | null) {
const label = t === "dumps"
? "Dumps"
: t === "users"
? "Users"
: "Playlists";
return count !== null ? `${label} (${count})` : label;
function tabLabel(tab: Tab, count: number | null) {
const label = tab === "dumps"
? t`Dumps`
: tab === "users"
? t`Users`
: t`Playlists`;
return count !== null ? t`${label} (${count})` : label;
}
return (
@@ -180,18 +182,18 @@ export function Search() {
<main className="search-page">
{q && (
<div className="search-tabs">
{(["dumps", "users", "playlists"] as Tab[]).map((t) => (
{(["dumps", "users", "playlists"] as Tab[]).map((tabKey) => (
<button
key={t}
key={tabKey}
type="button"
className={`feed-sort-btn${tab === t ? " active" : ""}`}
onClick={() => setTab(t)}
className={`feed-sort-btn${tab === tabKey ? " active" : ""}`}
onClick={() => setTab(tabKey)}
>
{tabLabel(
t,
t === "dumps"
tabKey,
tabKey === "dumps"
? dumpCount
: t === "users"
: tabKey === "users"
? userCount
: playlistCount,
)}
@@ -201,21 +203,21 @@ export function Search() {
)}
{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" && (
<p className="search-page-empty">Searching</p>
<p className="search-page-empty"><Trans>Searching</Trans></p>
)}
{state.status === "error" && (
<ErrorCard title="Search failed" message={state.error} />
<ErrorCard title={t`Search failed`} message={state.error} />
)}
{state.status === "loaded" && tab === "dumps" && (
<>
{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">
{state.dumps.items.map((dump) => (
@@ -234,17 +236,17 @@ export function Search() {
)}
<div ref={sentinelRef} />
{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 && (
<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.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">
{state.users.map((u) => (
@@ -268,7 +270,7 @@ export function Search() {
{state.status === "loaded" && tab === "playlists" && (
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">
{state.playlists.map((p) => (

View File

@@ -1,4 +1,6 @@
import { useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans, Plural } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
@@ -45,7 +47,7 @@ export function UserDumps() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
<p className="page-loading"><Trans>Loading</Trans></p>
</PageShell>
);
}
@@ -56,7 +58,7 @@ export function UserDumps() {
message={state.error}
actions={
<Link to={`/users/${username}`} className="btn-border">
Back to profile
<Trans> Back to profile</Trans>
</Link>
}
/>
@@ -70,14 +72,14 @@ export function UserDumps() {
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Dumps"
title={t`Dumps`}
actions={isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+ New dump
<Trans>+ New dump</Trans>
</button>
)}
/>
@@ -87,7 +89,7 @@ export function UserDumps() {
)}
{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">
{dumps.map((dump) => (
@@ -106,9 +108,11 @@ export function UserDumps() {
)}
<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 && (
<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>
);

View File

@@ -1,6 +1,8 @@
import { useState } from "react";
import type { SubmitEvent } from "react";
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 {
@@ -57,17 +59,17 @@ export function UserLogin() {
return (
<PageShell centered>
<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" && (
<ErrorCard title="Login failed" message={state.error} />
<ErrorCard title={t`Login failed`} message={state.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder="Username"
placeholder={t`Username`}
required
disabled={state.status === "submitting"}
autoFocus
@@ -75,7 +77,7 @@ export function UserLogin() {
<input
name="password"
type="password"
placeholder="Password"
placeholder={t`Password`}
required
disabled={state.status === "submitting"}
/>
@@ -84,12 +86,14 @@ export function UserLogin() {
className="btn-primary"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Logging in…" : "Log in"}
{state.status === "submitting"
? <Trans>Logging in</Trans>
: <Trans>Log in</Trans>}
</button>
</form>
<p className="auth-card-footer">
This is a mirage.
<Trans>This is a mirage.</Trans>
</p>
</div>
</PageShell>

View File

@@ -6,6 +6,8 @@ import {
useState,
} from "react";
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 { friendlyFetchError } from "../utils/apiError.ts";
@@ -74,6 +76,11 @@ export function UserPlaylists() {
);
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 profileUserId = state.status === "loaded" ? state.profileUser.id : null;
@@ -114,7 +121,6 @@ export function UserPlaylists() {
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
const controller = new AbortController();
const authHeaders: HeadersInit = token
@@ -190,7 +196,7 @@ export function UserPlaylists() {
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username]);
}, [username, cachedCreated, cachedFollowed, token]);
const loadMoreCreated = useCallback(() => {
if (
@@ -332,7 +338,7 @@ export function UserPlaylists() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
<p className="page-loading"><Trans>Loading</Trans></p>
</PageShell>
);
}
@@ -343,7 +349,7 @@ export function UserPlaylists() {
message={state.error}
actions={
<Link to={`/users/${username}`} className="btn-border">
Back to profile
<Trans> Back to profile</Trans>
</Link>
}
/>
@@ -357,7 +363,7 @@ export function UserPlaylists() {
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Playlists"
title={t`Playlists`}
actions={isOwnProfile && (
<NewPlaylistForm
toggleClassName="btn-primary"
@@ -377,12 +383,13 @@ export function UserPlaylists() {
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
Created ({created.items.length}
{created.hasMore ? "+" : ""})
<Trans>
Created ({created.items.length}{created.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
{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">
{created.items.map((p) => (
@@ -399,19 +406,24 @@ export function UserPlaylists() {
)}
<div ref={createdSentinelRef} />
{created.loadingMore && (
<p className="feed-loading-more">Loading more</p>
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
)}
</section>
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
Followed ({followed.items.length}
{followed.hasMore ? "+" : ""})
<Trans>
Followed ({followed.items.length}{followed.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
{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">
{followed.items.map((p) => (
@@ -421,14 +433,14 @@ export function UserPlaylists() {
)}
<div ref={followedSentinelRef} />
{followed.loadingMore && (
<p className="feed-loading-more">Loading more</p>
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
)}
</section>
{confirmDeleteId && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
message={t`Delete this playlist? This cannot be undone.`}
confirmLabel={t`Delete playlist`}
onConfirm={() => {
handleDelete(confirmDeleteId);
setConfirmDeleteId(null);

View File

@@ -6,6 +6,8 @@ import React, {
useState,
} from "react";
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 type { Dump, PaginatedData, PublicUser } from "../model.ts";
@@ -57,10 +59,10 @@ function InviteButton() {
`${globalThis.location.origin}/register?token=${body.data.token}`;
setInviteUrl(url);
} else {
setError("Failed to generate invite");
setError(t`Failed to generate invite`);
}
} catch {
setError("Failed to generate invite");
setError(t`Failed to generate invite`);
}
}
@@ -76,7 +78,7 @@ function InviteButton() {
<div className="invite-result">
<span className="invite-url">{inviteUrl}</span>
<button type="button" className="invite-copy-btn" onClick={copy}>
{copied ? "Copied!" : "Copy"}
{copied ? t`Copied!` : t`Copy`}
</button>
</div>
);
@@ -85,9 +87,9 @@ function InviteButton() {
return (
<div className="invite-generate">
<button type="button" className="invite-btn" onClick={generate}>
+ Invite someone
<Trans>+ Invite someone</Trans>
</button>
{error && <ErrorCard title="Failed to generate invite" message={error} />}
{error && <ErrorCard title={t`Failed to generate invite`} message={error} />}
</div>
);
}
@@ -240,10 +242,15 @@ export function UserPublicProfile() {
const [emailError, setEmailError] = useState<string | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => {
if (!username) return;
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
prevMyVotesRef.current = null;
}
useEffect(() => {
if (!username) return;
const controller = new AbortController();
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
@@ -358,7 +365,7 @@ export function UserPublicProfile() {
}
})();
return () => controller.abort();
}, [username]);
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
// Own profile: prepend dumps newly voted by the user to the preview list
useEffect(() => {
@@ -505,7 +512,7 @@ export function UserPublicProfile() {
: prev
);
} catch {
setAvatarError("Upload failed");
setAvatarError(t`Upload failed`);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
@@ -536,7 +543,7 @@ export function UserPublicProfile() {
}
setEmailEditing(false);
} catch {
setEmailError("Failed to save");
setEmailError(t`Failed to save`);
} finally {
setEmailSaving(false);
}
@@ -571,7 +578,7 @@ export function UserPublicProfile() {
);
setDescEditing(false);
} catch {
setDescError("Failed to save");
setDescError(t`Failed to save`);
} finally {
setDescSaving(false);
}
@@ -580,7 +587,7 @@ export function UserPublicProfile() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading profile</p>
<p className="page-loading"><Trans>Loading profile</Trans></p>
</PageShell>
);
}
@@ -596,11 +603,11 @@ export function UserPublicProfile() {
type="button"
onClick={() => navigate("/")}
>
Back
<Trans> Back</Trans>
</button>
{me && (
<button className="btn-border" type="button" onClick={logout}>
Log out
<Trans>Log out</Trans>
</button>
)}
</>
@@ -623,7 +630,7 @@ export function UserPublicProfile() {
version={profileUser.updatedAt?.getTime()}
/>
{isOwnProfile && (
<label className="avatar-change-overlay" title="Change avatar">
<label className="avatar-change-overlay" title={t`Change avatar`}>
{uploading ? "…" : "✎"}
<input
ref={fileInputRef}
@@ -641,7 +648,7 @@ export function UserPublicProfile() {
{profileUser.invitedByUsername
? (
<p className="profile-invited-by">
invited by{" "}
<Trans>invited by</Trans>{" "}
<Link
to={`/users/${profileUser.invitedByUsername}`}
className="profile-invited-by-link"
@@ -682,7 +689,7 @@ export function UserPublicProfile() {
className="profile-email-btn profile-email-btn--save"
disabled={emailSaving || !emailDraft.trim()}
>
{emailSaving ? "Saving…" : "Save"}
{emailSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
@@ -690,11 +697,11 @@ export function UserPublicProfile() {
onClick={() => setEmailEditing(false)}
disabled={emailSaving}
>
Cancel
<Trans>Cancel</Trans>
</button>
</div>
{emailError && (
<ErrorCard title="Failed to save" message={emailError} />
<ErrorCard title={t`Failed to save`} message={emailError} />
)}
</form>
)
@@ -708,7 +715,7 @@ export function UserPublicProfile() {
}}
title="Edit email"
>
{me?.email ?? "Add email…"}
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
</span>
@@ -716,7 +723,7 @@ export function UserPublicProfile() {
)
)}
{avatarError && (
<ErrorCard title="Failed to update avatar" message={avatarError} />
<ErrorCard title={t`Failed to update avatar`} message={avatarError} />
)}
{!isOwnProfile && (
<FollowUserButton
@@ -728,7 +735,7 @@ export function UserPublicProfile() {
<div className="profile-own-actions">
<InviteButton />
<button type="button" className="btn-border" onClick={logout}>
Log out
<Trans>Log out</Trans>
</button>
</div>
)}
@@ -745,7 +752,7 @@ export function UserPublicProfile() {
value={descDraft}
onChange={setDescDraft}
onSubmit={handleDescSave}
placeholder="Tell people about yourself…"
placeholder={t`Tell people about yourself…`}
autoResize
/>
<div className="profile-description-actions">
@@ -755,7 +762,7 @@ export function UserPublicProfile() {
onClick={handleDescSave}
disabled={descSaving}
>
{descSaving ? "Saving…" : "Save"}
{descSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
@@ -763,10 +770,10 @@ export function UserPublicProfile() {
onClick={() => setDescEditing(false)}
disabled={descSaving}
>
Cancel
<Trans>Cancel</Trans>
</button>
{descError && (
<ErrorCard title="Failed to save" message={descError} />
<ErrorCard title={t`Failed to save`} message={descError} />
)}
</div>
</div>
@@ -792,7 +799,7 @@ export function UserPublicProfile() {
)
: (
<div className="profile-description-empty">
Add a bio
<Trans>Add a bio</Trans>
</div>
)}
{isOwnProfile && (
@@ -807,7 +814,7 @@ export function UserPublicProfile() {
<div className="profile-columns">
<DumpList
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
dumps={dumps.items}
voteCounts={voteCounts}
myVotes={myVotes}
@@ -819,7 +826,7 @@ export function UserPublicProfile() {
/>
<UpvotedDumpList
title={`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
title={t`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
dumps={votes.items}
profileUserId={profileUserId}
isOwnProfile={isOwnProfile}
@@ -835,8 +842,7 @@ export function UserPublicProfile() {
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
<Trans>Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})</Trans>
</h2>
{isOwnProfile && (
<NewPlaylistForm
@@ -856,7 +862,7 @@ export function UserPublicProfile() {
)}
</div>
{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">
{playlists.items.map((p) => (
@@ -869,7 +875,7 @@ export function UserPublicProfile() {
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
View all
<Trans>View all </Trans>
</Link>
)}
</section>
@@ -913,7 +919,7 @@ function DumpList(
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+ New dump
<Trans>+ New dump</Trans>
</button>
)}
</div>
@@ -921,7 +927,7 @@ function DumpList(
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{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">
{dumps.map((dump) => (
@@ -939,7 +945,7 @@ function DumpList(
</ul>
)}
{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>
);
@@ -985,9 +991,17 @@ function UpvotedDumpList(
const prevMyVotesRef = useRef<Set<string> | null>(null);
// 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(() => {
if (!profileUserId || !isOwnProfile) return;
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));
prevMyVotesRef.current = new Set(wsMyVotes);
return;
@@ -1000,11 +1014,16 @@ function UpvotedDumpList(
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
// 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(() => {
if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
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) => {
const n = new Set(prev);
n.delete(dumpId);
@@ -1027,7 +1046,7 @@ function UpvotedDumpList(
<h2 className="profile-section-title">{title}</h2>
</div>
{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">
{visibleDumps.map((dump) => {
@@ -1054,7 +1073,7 @@ function UpvotedDumpList(
</ul>
)}
{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>
);

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import {
@@ -30,16 +32,19 @@ export function UserRegister() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const [tokenState, setTokenState] = useState<TokenState>({
status: "checking",
});
const [tokenState, setTokenState] = useState<TokenState>(() =>
token ? { status: "checking" } : { status: "invalid" }
);
const [formState, setFormState] = useState<FormState>({ status: "idle" });
const [prevToken, setPrevToken] = useState(token);
if (prevToken !== token) {
setPrevToken(token);
setTokenState(token ? { status: "checking" } : { status: "invalid" });
}
useEffect(() => {
if (!token) {
setTokenState({ status: "invalid" });
return;
}
if (!token) return;
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
.then((r) => {
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
@@ -86,7 +91,7 @@ export function UserRegister() {
if (tokenState.status === "checking") {
return (
<PageShell centered>
<p className="page-loading">Checking invite</p>
<p className="page-loading"><Trans>Checking invite</Trans></p>
</PageShell>
);
}
@@ -96,8 +101,8 @@ export function UserRegister() {
<PageShell centered>
<div className="page-error-wrap">
<ErrorCard
title="Invalid invite"
message="This invite link is missing, expired, or already used."
title={t`Invalid invite`}
message={t`This invite link is missing, expired, or already used.`}
/>
</div>
</PageShell>
@@ -107,34 +112,34 @@ export function UserRegister() {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">Register</h1>
<h1 className="auth-card-title"><Trans>Register</Trans></h1>
{formState.status === "error" && (
<ErrorCard title="Registration failed" message={formState.error} />
<ErrorCard title={t`Registration failed`} message={formState.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder="Username"
placeholder={t`Username`}
required
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"}
autoFocus
/>
<input
name="email"
type="email"
placeholder="Email address"
placeholder={t`Email address`}
required
disabled={formState.status === "submitting"}
/>
<input
name="password"
type="password"
placeholder={`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
required
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
@@ -145,12 +150,14 @@ export function UserRegister() {
className="btn-primary"
disabled={formState.status === "submitting"}
>
{formState.status === "submitting" ? "Registering…" : "Register"}
{formState.status === "submitting"
? <Trans>Registering</Trans>
: <Trans>Register</Trans>}
</button>
</form>
<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>
</div>
</PageShell>

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 type { Dump } from "../model.ts";
@@ -38,23 +40,36 @@ export function UserUpvoted() {
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(() => {
cancelAll();
setVotedIds(new Set());
prevMyVotesRef.current = null;
}, [username]);
}, [username, cancelAll]);
// Seed votedIds once items are loaded
useEffect(() => {
if (state.status !== "loaded") return;
setVotedIds(new Set(state.items.map((d) => d.id)));
}, [state.status]);
const [prevStateStatus, setPrevStateStatus] = useState(state.status);
const [prevStateItems, setPrevStateItems] = useState(
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)));
}
}
// Own profile: keep votedIds in sync with myVotes
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes);
return;
@@ -66,13 +81,13 @@ export function UserUpvoted() {
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId, startFading, cancelFading]);
// WS vote events
useEffect(() => {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
@@ -96,12 +111,12 @@ export function UserUpvoted() {
})
.catch(() => {});
}
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
}, [lastVoteEvent, profileUserId, startFading, cancelFading, setState]);
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
<p className="page-loading"><Trans>Loading</Trans></p>
</PageShell>
);
}
@@ -112,7 +127,7 @@ export function UserUpvoted() {
message={state.error}
actions={
<Link to={`/users/${username}`} className="btn-border">
Back to profile
<Trans> Back to profile</Trans>
</Link>
}
/>
@@ -129,11 +144,11 @@ export function UserUpvoted() {
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Upvoted"
title={t`Upvoted`}
/>
{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">
{visibleDumps.map((dump) => {
@@ -161,9 +176,13 @@ export function UserUpvoted() {
)}
<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 && (
<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>
);

View File

@@ -1,4 +1,6 @@
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 { ErrorCard } from "../../components/ErrorCard.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
@@ -69,10 +71,10 @@ function FollowedSubFeed({
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
if (state.status === "loading") {
return <p className="index-status">Loading</p>;
return <p className="index-status"><Trans>Loading</Trans></p>;
}
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));
@@ -98,7 +100,7 @@ function FollowedSubFeed({
))}
</ul>
<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" : ""}`}
onClick={() => setSection("users")}
>
From people
<Trans>From people</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${section === "playlists" ? " active" : ""}`}
onClick={() => setSection("playlists")}
>
From playlists
<Trans>From playlists</Trans>
</button>
</div>
@@ -340,7 +342,7 @@ export function FollowedFeed({
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some users to see their dumps here."
emptyMessage={t`Follow some users to see their dumps here.`}
onLoadMore={loadMoreUsers}
/>
)}
@@ -354,7 +356,7 @@ export function FollowedFeed({
castVote={castVote}
removeVote={removeVote}
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}
/>
)}

View File

@@ -1,4 +1,6 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import { hotScore } from "../../utils/hotScore.ts";
@@ -24,10 +26,10 @@ export function HotFeed(
[dumps],
);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
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 (
@@ -47,9 +49,9 @@ export function HotFeed(
))}
</ul>
<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 && (
<p className="feed-end">You've reached the end.</p>
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
)}
</>
);

View File

@@ -1,4 +1,6 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import {
JournalCard,
@@ -36,10 +38,10 @@ export function JournalFeed(
});
}, [dumps]);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
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 (
@@ -60,9 +62,9 @@ export function JournalFeed(
))}
</ul>
<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 && (
<p className="feed-end">You've reached the end.</p>
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
)}
</>
);

View File

@@ -1,4 +1,6 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import type { MainFeedProps } from "./types.ts";
@@ -24,10 +26,10 @@ export function NewFeed(
[dumps],
);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
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 (
@@ -47,9 +49,9 @@ export function NewFeed(
))}
</ul>
<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 && (
<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
View 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();
}
}