v3: added localization, use global player for uploaded audio/video files
This commit is contained in:
142
src/App.css
142
src/App.css
@@ -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;
|
||||
|
||||
106
src/App.tsx
106
src/App.tsx
@@ -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 |
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--logos"
|
||||
width="35.93"
|
||||
height="32"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 256 228"
|
||||
>
|
||||
<path
|
||||
fill="#00D8FF"
|
||||
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"
|
||||
></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,366 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="77"
|
||||
height="47"
|
||||
fill="none"
|
||||
aria-labelledby="vite-logo-title"
|
||||
viewBox="0 0 77 47"
|
||||
>
|
||||
<title id="vite-logo-title">Vite</title><style>
|
||||
.parenthesis {
|
||||
fill: #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.parenthesis {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style><path
|
||||
fill="#9135ff"
|
||||
d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"
|
||||
/><mask
|
||||
id="a"
|
||||
width="48"
|
||||
height="47"
|
||||
x="14"
|
||||
y="0"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style="mask-type: alpha"
|
||||
><path
|
||||
fill="#000"
|
||||
d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"
|
||||
/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse
|
||||
cx="5.508"
|
||||
cy="14.704"
|
||||
fill="#eee6ff"
|
||||
rx="5.508"
|
||||
ry="14.704"
|
||||
transform="rotate(269.814 20.96 11.29)scale(-1 1)"
|
||||
/></g><g filter="url(#c)"><ellipse
|
||||
cx="10.399"
|
||||
cy="29.851"
|
||||
fill="#eee6ff"
|
||||
rx="10.399"
|
||||
ry="29.851"
|
||||
transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"
|
||||
/></g><g filter="url(#d)"><ellipse
|
||||
cx="5.508"
|
||||
cy="30.487"
|
||||
fill="#8900ff"
|
||||
rx="5.508"
|
||||
ry="30.487"
|
||||
transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"
|
||||
/></g><g filter="url(#e)"><ellipse
|
||||
cx="5.508"
|
||||
cy="30.599"
|
||||
fill="#8900ff"
|
||||
rx="5.508"
|
||||
ry="30.599"
|
||||
transform="rotate(89.814 -25.928 4.177)scale(1 -1)"
|
||||
/></g><g filter="url(#f)"><ellipse
|
||||
cx="5.508"
|
||||
cy="30.599"
|
||||
fill="#8900ff"
|
||||
rx="5.508"
|
||||
ry="30.599"
|
||||
transform="rotate(89.814 -25.738 5.52)scale(1 -1)"
|
||||
/></g><g filter="url(#g)"><ellipse
|
||||
cx="14.072"
|
||||
cy="22.078"
|
||||
fill="#eee6ff"
|
||||
rx="14.072"
|
||||
ry="22.078"
|
||||
transform="rotate(93.35 31.245 55.578)scale(-1 1)"
|
||||
/></g><g filter="url(#h)"><ellipse
|
||||
cx="3.47"
|
||||
cy="21.501"
|
||||
fill="#8900ff"
|
||||
rx="3.47"
|
||||
ry="21.501"
|
||||
transform="rotate(89.009 35.419 55.202)scale(-1 1)"
|
||||
/></g><g filter="url(#i)"><ellipse
|
||||
cx="3.47"
|
||||
cy="21.501"
|
||||
fill="#8900ff"
|
||||
rx="3.47"
|
||||
ry="21.501"
|
||||
transform="rotate(89.009 35.419 55.202)scale(-1 1)"
|
||||
/></g><g filter="url(#j)"><ellipse
|
||||
cx="14.592"
|
||||
cy="9.743"
|
||||
fill="#8900ff"
|
||||
rx="4.407"
|
||||
ry="29.108"
|
||||
transform="rotate(39.51 14.592 9.743)"
|
||||
/></g><g filter="url(#k)"><ellipse
|
||||
cx="61.728"
|
||||
cy="-5.321"
|
||||
fill="#8900ff"
|
||||
rx="4.407"
|
||||
ry="29.108"
|
||||
transform="rotate(37.892 61.728 -5.32)"
|
||||
/></g><g filter="url(#l)"><ellipse
|
||||
cx="55.618"
|
||||
cy="7.104"
|
||||
fill="#00c2ff"
|
||||
rx="5.971"
|
||||
ry="9.665"
|
||||
transform="rotate(37.892 55.618 7.104)"
|
||||
/></g><g filter="url(#m)"><ellipse
|
||||
cx="12.326"
|
||||
cy="39.103"
|
||||
fill="#8900ff"
|
||||
rx="4.407"
|
||||
ry="29.108"
|
||||
transform="rotate(37.892 12.326 39.103)"
|
||||
/></g><g filter="url(#n)"><ellipse
|
||||
cx="12.326"
|
||||
cy="39.103"
|
||||
fill="#8900ff"
|
||||
rx="4.407"
|
||||
ry="29.108"
|
||||
transform="rotate(37.892 12.326 39.103)"
|
||||
/></g><g filter="url(#o)"><ellipse
|
||||
cx="49.857"
|
||||
cy="30.678"
|
||||
fill="#8900ff"
|
||||
rx="4.407"
|
||||
ry="29.108"
|
||||
transform="rotate(37.892 49.857 30.678)"
|
||||
/></g><g filter="url(#p)"><ellipse
|
||||
cx="52.623"
|
||||
cy="33.171"
|
||||
fill="#00c2ff"
|
||||
rx="5.971"
|
||||
ry="15.297"
|
||||
transform="rotate(37.892 52.623 33.17)"
|
||||
/></g></g><path
|
||||
d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789"
|
||||
class="parenthesis"
|
||||
/><defs><filter
|
||||
id="b"
|
||||
width="60.045"
|
||||
height="41.654"
|
||||
x="-5.564"
|
||||
y="16.92"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="7.659"
|
||||
/></filter><filter
|
||||
id="c"
|
||||
width="90.34"
|
||||
height="51.437"
|
||||
x="-40.407"
|
||||
y="-6.762"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="7.659"
|
||||
/></filter><filter
|
||||
id="d"
|
||||
width="79.355"
|
||||
height="29.4"
|
||||
x="-35.435"
|
||||
y="2.801"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="e"
|
||||
width="79.579"
|
||||
height="29.4"
|
||||
x="-30.84"
|
||||
y="20.8"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="f"
|
||||
width="79.579"
|
||||
height="29.4"
|
||||
x="-29.307"
|
||||
y="21.949"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="g"
|
||||
width="74.749"
|
||||
height="58.852"
|
||||
x="29.961"
|
||||
y="-17.13"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="7.659"
|
||||
/></filter><filter
|
||||
id="h"
|
||||
width="61.377"
|
||||
height="25.362"
|
||||
x="37.754"
|
||||
y="3.055"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="i"
|
||||
width="61.377"
|
||||
height="25.362"
|
||||
x="37.754"
|
||||
y="3.055"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="j"
|
||||
width="56.045"
|
||||
height="63.649"
|
||||
x="-13.43"
|
||||
y="-22.082"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="k"
|
||||
width="54.814"
|
||||
height="64.646"
|
||||
x="34.321"
|
||||
y="-37.644"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="l"
|
||||
width="33.541"
|
||||
height="35.313"
|
||||
x="38.847"
|
||||
y="-10.552"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="m"
|
||||
width="54.814"
|
||||
height="64.646"
|
||||
x="-15.081"
|
||||
y="6.78"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="n"
|
||||
width="54.814"
|
||||
height="64.646"
|
||||
x="-15.081"
|
||||
y="6.78"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="o"
|
||||
width="54.814"
|
||||
height="64.646"
|
||||
x="22.45"
|
||||
y="-1.645"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter><filter
|
||||
id="p"
|
||||
width="39.409"
|
||||
height="43.623"
|
||||
x="32.919"
|
||||
y="11.36"
|
||||
color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/><feGaussianBlur
|
||||
result="effect1_foregroundBlur_2002_17286"
|
||||
stdDeviation="4.596"
|
||||
/></filter></defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { 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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
🔍
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
3
src/config/feedTabs.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const FEED_TABS = ["hot", "new", "journal", "followed"] as const;
|
||||
export type FeedTab = (typeof FEED_TABS)[number];
|
||||
export const VALID_TABS = new Set<string>(FEED_TABS);
|
||||
@@ -1,19 +1,43 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
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: () => {},
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
20
src/i18n.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
const SUPPORTED = ["en", "fr"] as const;
|
||||
type Locale = (typeof SUPPORTED)[number];
|
||||
|
||||
function detectLocale(): Locale {
|
||||
const stored = localStorage.getItem("locale");
|
||||
if (stored && (SUPPORTED as readonly string[]).includes(stored)) return stored as Locale;
|
||||
return navigator.language.startsWith("fr") ? "fr" : "en";
|
||||
}
|
||||
|
||||
export async function loadCatalog(locale: Locale = detectLocale()) {
|
||||
const { messages } = await import(`./locales/${locale}.po`);
|
||||
i18n.load(locale, messages);
|
||||
i18n.activate(locale);
|
||||
localStorage.setItem("locale", locale);
|
||||
}
|
||||
|
||||
export { i18n };
|
||||
export type { Locale };
|
||||
1
src/locales/en.js
Normal file
1
src/locales/en.js
Normal file
File diff suppressed because one or more lines are too long
1
src/locales/en.mjs
Normal file
1
src/locales/en.mjs
Normal file
File diff suppressed because one or more lines are too long
956
src/locales/en.po
Normal file
956
src/locales/en.po
Normal file
@@ -0,0 +1,956 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-03-31 06:22+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: en\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/CommentThread.tsx:170
|
||||
msgid "[deleted]"
|
||||
msgstr "[deleted]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: src/components/DumpCard.tsx:82
|
||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||
|
||||
#. placeholder {0}: playlist.dumpCount
|
||||
#: src/components/PlaylistCard.tsx:84
|
||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
||||
|
||||
#. placeholder {0}: VALIDATION.USERNAME_MIN
|
||||
#. placeholder {1}: VALIDATION.USERNAME_MAX
|
||||
#: src/pages/UserRegister.tsx:128
|
||||
msgid "{0}–{1} characters: letters, numbers, or underscores"
|
||||
msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
||||
|
||||
#: src/pages/Notifications.tsx:184
|
||||
msgid "{days}d ago"
|
||||
msgstr "{days}d ago"
|
||||
|
||||
#: src/pages/Notifications.tsx:182
|
||||
msgid "{hrs}h ago"
|
||||
msgstr "{hrs}h ago"
|
||||
|
||||
#: src/pages/Search.tsx:176
|
||||
msgid "{label} ({count})"
|
||||
msgstr "{label} ({count})"
|
||||
|
||||
#: src/pages/Notifications.tsx:180
|
||||
msgid "{mins}m ago"
|
||||
msgstr "{mins}m ago"
|
||||
|
||||
#: src/components/CommentThread.tsx:436
|
||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:605
|
||||
#: src/pages/UserPublicProfile.tsx:606
|
||||
msgid "← Back"
|
||||
msgstr "← Back"
|
||||
|
||||
#: src/pages/Dump.tsx:216
|
||||
#: src/pages/Dump.tsx:318
|
||||
#: src/pages/DumpEdit.tsx:166
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Back to all dumps"
|
||||
|
||||
#: src/pages/UserDumps.tsx:61
|
||||
#: src/pages/UserPlaylists.tsx:352
|
||||
#: src/pages/UserUpvoted.tsx:130
|
||||
msgid "← Back to profile"
|
||||
msgstr "← Back to profile"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:90
|
||||
msgid "+ Invite someone"
|
||||
msgstr "+ Invite someone"
|
||||
|
||||
#: src/components/AppHeader.tsx:63
|
||||
msgid "+ New"
|
||||
msgstr "+ New"
|
||||
|
||||
#: src/pages/UserDumps.tsx:82
|
||||
#: src/pages/UserPublicProfile.tsx:922
|
||||
msgid "+ New dump"
|
||||
msgstr "+ New dump"
|
||||
|
||||
#: src/components/PlaylistMembershipPanel.tsx:72
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ New playlist"
|
||||
|
||||
#: src/pages/Dump.tsx:248
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Playlist"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:124
|
||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.mentionerUsername
|
||||
#: src/pages/Notifications.tsx:166
|
||||
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:134
|
||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> posted <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#: src/pages/Notifications.tsx:115
|
||||
msgid "<0>{0}</0> started following you"
|
||||
msgstr "<0>{0}</0> started following you"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:154
|
||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumpTitle
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:144
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
||||
|
||||
#: src/pages/Notifications.tsx:164
|
||||
msgid "a comment"
|
||||
msgstr "a comment"
|
||||
|
||||
#: src/pages/Notifications.tsx:164
|
||||
msgid "a post"
|
||||
msgstr "a post"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:802
|
||||
msgid "Add a bio…"
|
||||
msgstr "Add a bio…"
|
||||
|
||||
#: src/components/CommentThread.tsx:456
|
||||
msgid "Add a comment…"
|
||||
msgstr "Add a comment…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:718
|
||||
msgid "Add email…"
|
||||
msgstr "Add email…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:262
|
||||
msgid "Add to playlist"
|
||||
msgstr "Add to playlist"
|
||||
|
||||
#: api/auth:
|
||||
#~ msgid "Admin access required"
|
||||
#~ msgstr "Admin access required"
|
||||
|
||||
#. placeholder {0}: dumps.length
|
||||
#: src/pages/UserDumps.tsx:114
|
||||
msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||
msgstr "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||
|
||||
#. placeholder {0}: votes.length
|
||||
#: src/pages/UserUpvoted.tsx:184
|
||||
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||
msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||
|
||||
#: src/pages/UserRegister.tsx:160
|
||||
msgid "Already have an account? <0>Log in</0>"
|
||||
msgstr "Already have an account? <0>Log in</0>"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:168
|
||||
#: src/contexts/WSProvider.tsx:360
|
||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
|
||||
#: src/components/CommentThread.tsx:268
|
||||
#: src/components/CommentThread.tsx:353
|
||||
#: src/components/CommentThread.tsx:483
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/DumpCreateModal.tsx:394
|
||||
#: src/components/PlaylistCreateForm.tsx:105
|
||||
#: src/pages/DumpEdit.tsx:288
|
||||
#: src/pages/PlaylistDetail.tsx:672
|
||||
#: src/pages/UserPublicProfile.tsx:700
|
||||
#: src/pages/UserPublicProfile.tsx:773
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:848
|
||||
msgid "Cancel removal"
|
||||
msgstr "Cancel removal"
|
||||
|
||||
#: api/comments:
|
||||
#~ msgid "Cannot edit a deleted comment"
|
||||
#~ msgstr "Cannot edit a deleted comment"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:633
|
||||
msgid "Change avatar"
|
||||
msgstr "Change avatar"
|
||||
|
||||
#: src/pages/UserRegister.tsx:94
|
||||
msgid "Checking invite…"
|
||||
msgstr "Checking invite…"
|
||||
|
||||
#: src/components/Modal.tsx:45
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#: api/comments:
|
||||
#~ msgid "Comment not found"
|
||||
#~ msgstr "Comment not found"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:81
|
||||
msgid "Copied!"
|
||||
msgstr "Copied!"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:81
|
||||
msgid "Copy"
|
||||
msgstr "Copy"
|
||||
|
||||
#: src/components/CommentThread.tsx:108
|
||||
#: src/components/CommentThread.tsx:147
|
||||
#: src/components/CommentThread.tsx:425
|
||||
msgid "Could not reach the server. Please try again."
|
||||
msgstr "Could not reach the server. Please try again."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:116
|
||||
msgid "Create"
|
||||
msgstr "Create"
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:115
|
||||
msgid "Create & Add"
|
||||
msgstr "Create & Add"
|
||||
|
||||
#. placeholder {0}: created.items.length
|
||||
#. placeholder {1}: created.hasMore ? "+" : ""
|
||||
#: src/pages/UserPlaylists.tsx:386
|
||||
msgid "Created ({0}{1})"
|
||||
msgstr "Created ({0}{1})"
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:113
|
||||
msgid "Creating…"
|
||||
msgstr "Creating…"
|
||||
|
||||
#: src/components/CommentThread.tsx:306
|
||||
#: src/components/CommentThread.tsx:312
|
||||
#: src/components/ConfirmModal.tsx:16
|
||||
#: src/pages/PlaylistDetail.tsx:679
|
||||
msgid "Delete"
|
||||
msgstr "Delete"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:284
|
||||
#: src/pages/DumpEdit.tsx:300
|
||||
msgid "Delete dump"
|
||||
msgstr "Delete dump"
|
||||
|
||||
#: src/components/PlaylistCard.tsx:107
|
||||
#: src/pages/PlaylistDetail.tsx:861
|
||||
#: src/pages/UserPlaylists.tsx:443
|
||||
msgid "Delete playlist"
|
||||
msgstr "Delete playlist"
|
||||
|
||||
#: src/components/CommentThread.tsx:311
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Delete this comment?"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:299
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Delete this dump? This cannot be undone."
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:860
|
||||
#: src/pages/UserPlaylists.tsx:442
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
msgstr "Delete this playlist? This cannot be undone."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:76
|
||||
#: src/pages/PlaylistDetail.tsx:710
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (optional)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:439
|
||||
msgid "Done"
|
||||
msgstr "Done"
|
||||
|
||||
#: src/components/FileDropZone.tsx:32
|
||||
msgid "Drop a file here"
|
||||
msgstr "Drop a file here"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:242
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Drop a replacement here"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:405
|
||||
msgid "Dump it"
|
||||
msgstr "Dump it"
|
||||
|
||||
#: api/dumps:
|
||||
#~ msgid "Dump not found"
|
||||
#~ msgstr "Dump not found"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:416
|
||||
msgid "Dumped!"
|
||||
msgstr "Dumped!"
|
||||
|
||||
#: src/pages/Search.tsx:172
|
||||
#: src/pages/UserDumps.tsx:75
|
||||
msgid "Dumps"
|
||||
msgstr "Dumps"
|
||||
|
||||
#. placeholder {0}: dumps.items.length
|
||||
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:817
|
||||
msgid "Dumps ({0}{1})"
|
||||
msgstr "Dumps ({0}{1})"
|
||||
|
||||
#: src/pages/Notifications.tsx:341
|
||||
msgid "Earlier"
|
||||
msgstr "Earlier"
|
||||
|
||||
#: src/components/CommentThread.tsx:297
|
||||
#: src/pages/Dump.tsx:315
|
||||
#: src/pages/PlaylistDetail.tsx:698
|
||||
msgid "Edit"
|
||||
msgstr "Edit"
|
||||
|
||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
||||
#: src/components/CommentThread.tsx:231
|
||||
#: src/pages/Dump.tsx:276
|
||||
#: src/pages/PlaylistDetail.tsx:768
|
||||
msgid "edited {0}"
|
||||
msgstr "edited {0}"
|
||||
|
||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
||||
#: src/components/CommentThread.tsx:229
|
||||
#: src/pages/Dump.tsx:274
|
||||
#: src/pages/PlaylistDetail.tsx:765
|
||||
msgid "Edited {0}"
|
||||
msgstr "Edited {0}"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:180
|
||||
msgid "Editing"
|
||||
msgstr "Editing"
|
||||
|
||||
#: src/pages/UserRegister.tsx:135
|
||||
msgid "Email address"
|
||||
msgstr "Email address"
|
||||
|
||||
#: src/pages/Search.tsx:206
|
||||
msgid "Enter a query to search."
|
||||
msgstr "Enter a query to search."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:59
|
||||
#: src/components/PlaylistCreateForm.tsx:97
|
||||
msgid "Failed to create playlist"
|
||||
msgstr "Failed to create playlist"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:62
|
||||
#: src/pages/UserPublicProfile.tsx:65
|
||||
#: src/pages/UserPublicProfile.tsx:92
|
||||
msgid "Failed to generate invite"
|
||||
msgstr "Failed to generate invite"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:77
|
||||
#: src/pages/index/HotFeed.tsx:30
|
||||
#: src/pages/index/JournalFeed.tsx:42
|
||||
#: src/pages/index/NewFeed.tsx:30
|
||||
#: src/pages/Notifications.tsx:321
|
||||
msgid "Failed to load"
|
||||
msgstr "Failed to load"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:300
|
||||
msgid "Failed to post"
|
||||
msgstr "Failed to post"
|
||||
|
||||
#: src/components/CommentThread.tsx:462
|
||||
msgid "Failed to post comment"
|
||||
msgstr "Failed to post comment"
|
||||
|
||||
#: src/components/CommentThread.tsx:334
|
||||
msgid "Failed to post reply"
|
||||
msgstr "Failed to post reply"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:776
|
||||
#: src/pages/UserPublicProfile.tsx:546
|
||||
#: src/pages/UserPublicProfile.tsx:581
|
||||
#: src/pages/UserPublicProfile.tsx:704
|
||||
#: src/pages/UserPublicProfile.tsx:776
|
||||
msgid "Failed to save"
|
||||
msgstr "Failed to save"
|
||||
|
||||
#: src/components/CommentThread.tsx:249
|
||||
msgid "Failed to save edit"
|
||||
msgstr "Failed to save edit"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:726
|
||||
msgid "Failed to update avatar"
|
||||
msgstr "Failed to update avatar"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:333
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Fetching preview…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:403
|
||||
msgid "Fetching…"
|
||||
msgstr "Fetching…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:293
|
||||
#: src/components/FileDropZone.tsx:31
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: api/avatars:
|
||||
#~ msgid "File content is not a recognised image (JPEG, PNG, GIF, WebP)"
|
||||
#~ msgstr "File content is not a recognised image (JPEG, PNG, GIF, WebP)"
|
||||
|
||||
#: api/avatars:
|
||||
#~ msgid "File too large (max 5 MB)"
|
||||
#~ msgstr "File too large (max 5 MB)"
|
||||
|
||||
#: api/dumps:
|
||||
#~ msgid "File too large (max 50 MB)"
|
||||
#~ msgstr "File too large (max 50 MB)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:187
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "File too large (max 50 MB)."
|
||||
|
||||
#: src/components/FollowButton.tsx:37
|
||||
#: src/components/FollowButton.tsx:64
|
||||
msgid "Follow"
|
||||
msgstr "Follow"
|
||||
|
||||
#: src/components/FollowButton.tsx:35
|
||||
msgid "Follow {targetUsername}"
|
||||
msgstr "Follow {targetUsername}"
|
||||
|
||||
#: src/components/FollowButton.tsx:62
|
||||
msgid "Follow playlist"
|
||||
msgstr "Follow playlist"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:359
|
||||
msgid "Follow some public playlists to see their dumps here."
|
||||
msgstr "Follow some public playlists to see their dumps here."
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:345
|
||||
msgid "Follow some users to see their dumps here."
|
||||
msgstr "Follow some users to see their dumps here."
|
||||
|
||||
#: src/components/FeedTabBar.tsx:47
|
||||
msgid "Followed"
|
||||
msgstr "Followed"
|
||||
|
||||
#. placeholder {0}: followed.items.length
|
||||
#. placeholder {1}: followed.hasMore ? "+" : ""
|
||||
#: src/pages/UserPlaylists.tsx:416
|
||||
msgid "Followed ({0}{1})"
|
||||
msgstr "Followed ({0}{1})"
|
||||
|
||||
#: src/components/FollowButton.tsx:37
|
||||
#: src/components/FollowButton.tsx:64
|
||||
msgid "Following"
|
||||
msgstr "Following"
|
||||
|
||||
#: api/playlists:
|
||||
#~ msgid "Forbidden"
|
||||
#~ msgstr "Forbidden"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:325
|
||||
msgid "From people"
|
||||
msgstr "From people"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:332
|
||||
msgid "From playlists"
|
||||
msgstr "From playlists"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:25
|
||||
msgid "Hot"
|
||||
msgstr "Hot"
|
||||
|
||||
#: api/auth:
|
||||
#~ msgid "Invalid email address"
|
||||
#~ msgstr "Invalid email address"
|
||||
|
||||
#: src/pages/UserRegister.tsx:104
|
||||
msgid "Invalid invite"
|
||||
msgstr "Invalid invite"
|
||||
|
||||
#: api/invites:
|
||||
#~ msgid "Invalid or expired invite"
|
||||
#~ msgstr "Invalid or expired invite"
|
||||
|
||||
#: api/dumps:
|
||||
#~ msgid "Invalid URL"
|
||||
#~ msgstr "Invalid URL"
|
||||
|
||||
#. Backend error strings (manually maintained)
|
||||
#: api/auth:
|
||||
#~ msgid "Invalid username or password"
|
||||
#~ msgstr "Invalid username or password"
|
||||
|
||||
#: api/invites:
|
||||
#~ msgid "Invite already used"
|
||||
#~ msgstr "Invite already used"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:651
|
||||
msgid "invited by"
|
||||
msgstr "invited by"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:39
|
||||
msgid "Journal"
|
||||
msgstr "Journal"
|
||||
|
||||
#: src/pages/Notifications.tsx:178
|
||||
msgid "just now"
|
||||
msgstr "just now"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:359
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
|
||||
#: src/components/AppHeader.tsx:79
|
||||
msgid "Live updates unavailable."
|
||||
msgstr "Live updates unavailable."
|
||||
|
||||
#: src/pages/Notifications.tsx:386
|
||||
msgid "Load more"
|
||||
msgstr "Load more"
|
||||
|
||||
#: src/pages/Dump.tsx:193
|
||||
#: src/pages/DumpEdit.tsx:143
|
||||
msgid "Loading dump…"
|
||||
msgstr "Loading dump…"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:103
|
||||
#: src/pages/index/HotFeed.tsx:52
|
||||
#: src/pages/index/JournalFeed.tsx:65
|
||||
#: src/pages/index/NewFeed.tsx:52
|
||||
#: src/pages/Search.tsx:239
|
||||
#: src/pages/UserDumps.tsx:111
|
||||
#: src/pages/UserPlaylists.tsx:409
|
||||
#: src/pages/UserPlaylists.tsx:436
|
||||
#: src/pages/UserUpvoted.tsx:180
|
||||
msgid "Loading more…"
|
||||
msgstr "Loading more…"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:590
|
||||
msgid "Loading playlist…"
|
||||
msgstr "Loading playlist…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:590
|
||||
msgid "Loading profile…"
|
||||
msgstr "Loading profile…"
|
||||
|
||||
#: src/components/PlaylistMembershipPanel.tsx:26
|
||||
#: src/components/TextEditor.tsx:273
|
||||
#: src/pages/index/FollowedFeed.tsx:74
|
||||
#: src/pages/index/HotFeed.tsx:29
|
||||
#: src/pages/index/JournalFeed.tsx:41
|
||||
#: src/pages/index/NewFeed.tsx:29
|
||||
#: src/pages/Notifications.tsx:318
|
||||
#: src/pages/Notifications.tsx:386
|
||||
#: src/pages/UserDumps.tsx:50
|
||||
#: src/pages/UserPlaylists.tsx:341
|
||||
#: src/pages/UserUpvoted.tsx:119
|
||||
msgid "Loading…"
|
||||
msgstr "Loading…"
|
||||
|
||||
#: src/components/AppHeader.tsx:70
|
||||
#: src/pages/UserLogin.tsx:62
|
||||
#: src/pages/UserLogin.tsx:91
|
||||
msgid "Log in"
|
||||
msgstr "Log in"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:610
|
||||
#: src/pages/UserPublicProfile.tsx:738
|
||||
msgid "Log out"
|
||||
msgstr "Log out"
|
||||
|
||||
#: src/pages/UserLogin.tsx:90
|
||||
msgid "Logging in…"
|
||||
msgstr "Logging in…"
|
||||
|
||||
#: src/pages/UserLogin.tsx:65
|
||||
msgid "Login failed"
|
||||
msgstr "Login failed"
|
||||
|
||||
#: src/components/FileDropZone.tsx:141
|
||||
msgid "Max 50 MB"
|
||||
msgstr "Max 50 MB"
|
||||
|
||||
#: src/pages/Notifications.tsx:312
|
||||
msgid "new"
|
||||
msgstr "new"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:32
|
||||
msgid "New"
|
||||
msgstr "New"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:262
|
||||
msgid "New dump"
|
||||
msgstr "New dump"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:783
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "No dumps in this playlist yet."
|
||||
|
||||
#: src/pages/Search.tsx:220
|
||||
msgid "No dumps match \"{q}\"."
|
||||
msgstr "No dumps match \"{q}\"."
|
||||
|
||||
#: src/pages/index/HotFeed.tsx:32
|
||||
#: src/pages/index/JournalFeed.tsx:44
|
||||
#: src/pages/index/NewFeed.tsx:32
|
||||
msgid "No dumps yet. Be the first!"
|
||||
msgstr "No dumps yet. Be the first!"
|
||||
|
||||
#: src/components/TextEditor.tsx:274
|
||||
msgid "No emoji found."
|
||||
msgstr "No emoji found."
|
||||
|
||||
#: src/pages/UserPlaylists.tsx:424
|
||||
msgid "No followed playlists yet."
|
||||
msgstr "No followed playlists yet."
|
||||
|
||||
#: src/pages/Search.tsx:273
|
||||
msgid "No playlists match \"{q}\"."
|
||||
msgstr "No playlists match \"{q}\"."
|
||||
|
||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||
#: src/pages/UserPlaylists.tsx:392
|
||||
#: src/pages/UserPublicProfile.tsx:865
|
||||
msgid "No playlists yet."
|
||||
msgstr "No playlists yet."
|
||||
|
||||
#: src/pages/Search.tsx:249
|
||||
msgid "No users match \"{q}\"."
|
||||
msgstr "No users match \"{q}\"."
|
||||
|
||||
#: api/auth:
|
||||
#~ msgid "Not authenticated"
|
||||
#~ msgstr "Not authenticated"
|
||||
|
||||
#: src/pages/Notifications.tsx:327
|
||||
#: src/pages/UserDumps.tsx:92
|
||||
#: src/pages/UserPublicProfile.tsx:930
|
||||
#: src/pages/UserPublicProfile.tsx:1049
|
||||
#: src/pages/UserUpvoted.tsx:151
|
||||
msgid "Nothing here yet."
|
||||
msgstr "Nothing here yet."
|
||||
|
||||
#: src/components/NotificationBell.tsx:42
|
||||
#: src/pages/Notifications.tsx:308
|
||||
msgid "Notifications"
|
||||
msgstr "Notifications"
|
||||
|
||||
#: src/components/NotificationBell.tsx:41
|
||||
msgid "Notifications ({unreadNotificationCount} unread)"
|
||||
msgstr "Notifications ({unreadNotificationCount} unread)"
|
||||
|
||||
#: src/components/SearchBar.tsx:71
|
||||
msgid "Open search"
|
||||
msgstr "Open search"
|
||||
|
||||
#: src/components/FileDropZone.tsx:139
|
||||
msgid "or <0>browse files</0>"
|
||||
msgstr "or <0>browse files</0>"
|
||||
|
||||
#: src/pages/UserLogin.tsx:80
|
||||
msgid "Password"
|
||||
msgstr "Password"
|
||||
|
||||
#. placeholder {0}: VALIDATION.PASSWORD_MIN
|
||||
#: src/pages/UserRegister.tsx:142
|
||||
msgid "Password (min. {0} characters)"
|
||||
msgstr "Password (min. {0} characters)"
|
||||
|
||||
#: api/auth:
|
||||
#~ msgid "Password must be at least 8 characters"
|
||||
#~ msgstr "Password must be at least 8 characters"
|
||||
|
||||
#: api/auth:
|
||||
#~ msgid "Password must be at most 128 characters"
|
||||
#~ msgstr "Password must be at most 128 characters"
|
||||
|
||||
#: api/playlists:
|
||||
#~ msgid "Playlist not found"
|
||||
#~ msgstr "Playlist not found"
|
||||
|
||||
#: src/components/AppHeader.tsx:46
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:175
|
||||
#: src/pages/UserPlaylists.tsx:366
|
||||
msgid "Playlists"
|
||||
msgstr "Playlists"
|
||||
|
||||
#. placeholder {0}: playlists.items.length
|
||||
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:845
|
||||
msgid "Playlists ({0}{1})"
|
||||
msgstr "Playlists ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:180
|
||||
msgid "Please select a file."
|
||||
msgstr "Please select a file."
|
||||
|
||||
#: src/components/CommentThread.tsx:472
|
||||
msgid "Post comment"
|
||||
msgstr "Post comment"
|
||||
|
||||
#: src/components/CommentThread.tsx:342
|
||||
msgid "Post reply"
|
||||
msgstr "Post reply"
|
||||
|
||||
#: src/components/CommentThread.tsx:342
|
||||
#: src/components/CommentThread.tsx:472
|
||||
msgid "Posting…"
|
||||
msgstr "Posting…"
|
||||
|
||||
#: src/components/DumpCard.tsx:91
|
||||
#: src/components/PlaylistCard.tsx:71
|
||||
#: src/components/PlaylistMembershipPanel.tsx:47
|
||||
#: src/pages/Dump.tsx:282
|
||||
#: src/pages/PlaylistDetail.tsx:748
|
||||
msgid "private"
|
||||
msgstr "private"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:383
|
||||
#: src/components/PlaylistCreateForm.tsx:94
|
||||
#: src/pages/DumpEdit.tsx:274
|
||||
#: src/pages/PlaylistDetail.tsx:737
|
||||
msgid "Private"
|
||||
msgstr "Private"
|
||||
|
||||
#: src/components/PlaylistCard.tsx:71
|
||||
#: src/pages/PlaylistDetail.tsx:748
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:375
|
||||
#: src/components/PlaylistCreateForm.tsx:87
|
||||
#: src/pages/DumpEdit.tsx:267
|
||||
#: src/pages/PlaylistDetail.tsx:730
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:206
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Refresh metadata"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:206
|
||||
msgid "Refreshing…"
|
||||
msgstr "Refreshing…"
|
||||
|
||||
#: src/pages/UserRegister.tsx:115
|
||||
#: src/pages/UserRegister.tsx:155
|
||||
msgid "Register"
|
||||
msgstr "Register"
|
||||
|
||||
#: src/pages/UserRegister.tsx:154
|
||||
msgid "Registering…"
|
||||
msgstr "Registering…"
|
||||
|
||||
#: src/pages/UserRegister.tsx:118
|
||||
msgid "Registration failed"
|
||||
msgstr "Registration failed"
|
||||
|
||||
#: src/components/FileDropZone.tsx:115
|
||||
msgid "Remove file"
|
||||
msgstr "Remove file"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:838
|
||||
msgid "Remove from playlist"
|
||||
msgstr "Remove from playlist"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:241
|
||||
msgid "Replace file"
|
||||
msgstr "Replace file"
|
||||
|
||||
#: src/components/CommentThread.tsx:284
|
||||
msgid "Reply"
|
||||
msgstr "Reply"
|
||||
|
||||
#: src/pages/Dump.tsx:209
|
||||
#: src/pages/DumpEdit.tsx:159
|
||||
msgid "Retry"
|
||||
msgstr "Retry"
|
||||
|
||||
#: src/components/CommentThread.tsx:257
|
||||
#: src/pages/DumpEdit.tsx:291
|
||||
#: src/pages/PlaylistDetail.tsx:665
|
||||
#: src/pages/UserPublicProfile.tsx:692
|
||||
#: src/pages/UserPublicProfile.tsx:765
|
||||
msgid "Save"
|
||||
msgstr "Save"
|
||||
|
||||
#: src/components/CommentThread.tsx:257
|
||||
#: src/pages/PlaylistDetail.tsx:665
|
||||
#: src/pages/UserPublicProfile.tsx:692
|
||||
#: src/pages/UserPublicProfile.tsx:765
|
||||
msgid "Saving…"
|
||||
msgstr "Saving…"
|
||||
|
||||
#: src/components/SearchBar.tsx:65
|
||||
msgid "Search"
|
||||
msgstr "Search"
|
||||
|
||||
#: src/components/SearchBar.tsx:61
|
||||
msgid "Search dumps, users, playlists…"
|
||||
msgstr "Search dumps, users, playlists…"
|
||||
|
||||
#: src/pages/Search.tsx:214
|
||||
msgid "Search failed"
|
||||
msgstr "Search failed"
|
||||
|
||||
#: src/pages/Search.tsx:210
|
||||
msgid "Searching…"
|
||||
msgstr "Searching…"
|
||||
|
||||
#: src/components/AppHeader.tsx:61
|
||||
msgid "Server unreachable"
|
||||
msgstr "Server unreachable"
|
||||
|
||||
#: src/components/PageError.tsx:13
|
||||
msgid "Something went wrong"
|
||||
msgstr "Something went wrong"
|
||||
|
||||
#: src/components/SearchBar.tsx:71
|
||||
msgid "Submit search"
|
||||
msgstr "Submit search"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:755
|
||||
msgid "Tell people about yourself…"
|
||||
msgstr "Tell people about yourself…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:363
|
||||
#: src/pages/DumpEdit.tsx:256
|
||||
msgid "Tell the community what makes this worth their time..."
|
||||
msgstr "Tell the community what makes this worth their time..."
|
||||
|
||||
#: src/pages/UserRegister.tsx:105
|
||||
msgid "This invite link is missing, expired, or already used."
|
||||
msgstr "This invite link is missing, expired, or already used."
|
||||
|
||||
#: src/pages/UserLogin.tsx:96
|
||||
msgid "This is a mirage."
|
||||
msgstr "This is a mirage."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:69
|
||||
msgid "Title"
|
||||
msgstr "Title"
|
||||
|
||||
#: src/pages/Notifications.tsx:341
|
||||
msgid "Today"
|
||||
msgstr "Today"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:850
|
||||
msgid "Undo"
|
||||
msgstr "Undo"
|
||||
|
||||
#: api/generic:
|
||||
#~ msgid "Unexpected server error"
|
||||
#~ msgstr "Unexpected server error"
|
||||
|
||||
#: src/components/FollowButton.tsx:34
|
||||
msgid "Unfollow {targetUsername}"
|
||||
msgstr "Unfollow {targetUsername}"
|
||||
|
||||
#: src/components/FollowButton.tsx:62
|
||||
msgid "Unfollow playlist"
|
||||
msgstr "Unfollow playlist"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:515
|
||||
msgid "Upload failed"
|
||||
msgstr "Upload failed"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:404
|
||||
msgid "Uploading…"
|
||||
msgstr "Uploading…"
|
||||
|
||||
#: src/pages/UserUpvoted.tsx:147
|
||||
msgid "Upvoted"
|
||||
msgstr "Upvoted"
|
||||
|
||||
#. placeholder {0}: votes.items.length
|
||||
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:829
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Upvoted ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:309
|
||||
#: src/pages/DumpEdit.tsx:221
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:164
|
||||
msgid "URL is required."
|
||||
msgstr "URL is required."
|
||||
|
||||
#: src/components/UserMenu.tsx:37
|
||||
msgid "User menu"
|
||||
msgstr "User menu"
|
||||
|
||||
#: src/pages/UserLogin.tsx:72
|
||||
#: src/pages/UserRegister.tsx:125
|
||||
msgid "Username"
|
||||
msgstr "Username"
|
||||
|
||||
#: api/auth:
|
||||
#~ msgid "Username already exists"
|
||||
#~ msgstr "Username already exists"
|
||||
|
||||
#: api/auth:
|
||||
#~ msgid "Username must be 1–32 characters and contain only letters, numbers, or underscores"
|
||||
#~ msgstr "Username must be 1–32 characters and contain only letters, numbers, or underscores"
|
||||
|
||||
#: src/pages/Search.tsx:174
|
||||
msgid "Users"
|
||||
msgstr "Users"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:878
|
||||
#: src/pages/UserPublicProfile.tsx:948
|
||||
#: src/pages/UserPublicProfile.tsx:1076
|
||||
msgid "View all →"
|
||||
msgstr "View all →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:418
|
||||
msgid "View dump →"
|
||||
msgstr "View dump →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:356
|
||||
#: src/pages/DumpEdit.tsx:250
|
||||
msgid "Why are you dumping this?"
|
||||
msgstr "Why are you dumping this?"
|
||||
|
||||
#: src/components/CommentThread.tsx:329
|
||||
msgid "Write a reply…"
|
||||
msgstr "Write a reply…"
|
||||
|
||||
#: src/pages/Notifications.tsx:341
|
||||
msgid "Yesterday"
|
||||
msgstr "Yesterday"
|
||||
|
||||
#: src/pages/Notifications.tsx:329
|
||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
|
||||
#: src/pages/index/HotFeed.tsx:54
|
||||
#: src/pages/index/JournalFeed.tsx:67
|
||||
#: src/pages/index/NewFeed.tsx:54
|
||||
#: src/pages/Search.tsx:242
|
||||
msgid "You've reached the end."
|
||||
msgstr "You've reached the end."
|
||||
870
src/locales/fr.po
Normal file
870
src/locales/fr.po
Normal file
@@ -0,0 +1,870 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-04-01 16:55+0000\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: fr\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: src/components/CommentThread.tsx:170
|
||||
msgid "[deleted]"
|
||||
msgstr "[supprimé]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: src/components/DumpCard.tsx:82
|
||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||
|
||||
#. placeholder {0}: playlist.dumpCount
|
||||
#: src/components/PlaylistCard.tsx:84
|
||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||
msgstr "{0, plural, one {# reco} other {# recos}}"
|
||||
|
||||
#. placeholder {0}: VALIDATION.USERNAME_MIN
|
||||
#. placeholder {1}: VALIDATION.USERNAME_MAX
|
||||
#: src/pages/UserRegister.tsx:128
|
||||
msgid "{0}–{1} characters: letters, numbers, or underscores"
|
||||
msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
||||
|
||||
#: src/pages/Notifications.tsx:184
|
||||
msgid "{days}d ago"
|
||||
msgstr "il y a {days}j"
|
||||
|
||||
#: src/pages/Notifications.tsx:182
|
||||
msgid "{hrs}h ago"
|
||||
msgstr "il y a {hrs}h"
|
||||
|
||||
#: src/pages/Search.tsx:176
|
||||
msgid "{label} ({count})"
|
||||
msgstr "{label} ({count})"
|
||||
|
||||
#: src/pages/Notifications.tsx:180
|
||||
msgid "{mins}m ago"
|
||||
msgstr "il y a {mins}min"
|
||||
|
||||
#: src/components/CommentThread.tsx:436
|
||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:605
|
||||
#: src/pages/UserPublicProfile.tsx:606
|
||||
msgid "← Back"
|
||||
msgstr "← Retour"
|
||||
|
||||
#: src/pages/Dump.tsx:216
|
||||
#: src/pages/Dump.tsx:318
|
||||
#: src/pages/DumpEdit.tsx:166
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Retour à toutes les recos"
|
||||
|
||||
#: src/pages/UserDumps.tsx:61
|
||||
#: src/pages/UserPlaylists.tsx:352
|
||||
#: src/pages/UserUpvoted.tsx:130
|
||||
msgid "← Back to profile"
|
||||
msgstr "← Retour au profil"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:90
|
||||
msgid "+ Invite someone"
|
||||
msgstr "+ Inviter quelqu'un"
|
||||
|
||||
#: src/components/AppHeader.tsx:63
|
||||
msgid "+ New"
|
||||
msgstr "+ Nouveau"
|
||||
|
||||
#: src/pages/UserDumps.tsx:82
|
||||
#: src/pages/UserPublicProfile.tsx:922
|
||||
msgid "+ New dump"
|
||||
msgstr "+ Nouvelle reco"
|
||||
|
||||
#: src/components/PlaylistMembershipPanel.tsx:72
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ Nouvelle collection"
|
||||
|
||||
#: src/pages/Dump.tsx:248
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Collection"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:124
|
||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.mentionerUsername
|
||||
#: src/pages/Notifications.tsx:166
|
||||
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:134
|
||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a publié <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#: src/pages/Notifications.tsx:115
|
||||
msgid "<0>{0}</0> started following you"
|
||||
msgstr "<0>{0}</0> a commencé à vous suivre"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:154
|
||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumpTitle
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:144
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
||||
|
||||
#: src/pages/Notifications.tsx:164
|
||||
msgid "a comment"
|
||||
msgstr "un commentaire"
|
||||
|
||||
#: src/pages/Notifications.tsx:164
|
||||
msgid "a post"
|
||||
msgstr "une publication"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:802
|
||||
msgid "Add a bio…"
|
||||
msgstr "Ajouter une bio…"
|
||||
|
||||
#: src/components/CommentThread.tsx:456
|
||||
msgid "Add a comment…"
|
||||
msgstr "Ajouter un commentaire…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:718
|
||||
msgid "Add email…"
|
||||
msgstr "Ajouter un e-mail…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:262
|
||||
msgid "Add to playlist"
|
||||
msgstr "Ajouter à la collection"
|
||||
|
||||
#. placeholder {0}: dumps.length
|
||||
#: src/pages/UserDumps.tsx:114
|
||||
msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||
msgstr "Toutes les {0, plural, one {# reco} other {# recos}} chargées."
|
||||
|
||||
#. placeholder {0}: votes.length
|
||||
#: src/pages/UserUpvoted.tsx:184
|
||||
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||
msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} chargées."
|
||||
|
||||
#: src/pages/UserRegister.tsx:160
|
||||
msgid "Already have an account? <0>Log in</0>"
|
||||
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:168
|
||||
#: src/contexts/WSProvider.tsx:360
|
||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
||||
|
||||
#: src/components/CommentThread.tsx:268
|
||||
#: src/components/CommentThread.tsx:353
|
||||
#: src/components/CommentThread.tsx:483
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/DumpCreateModal.tsx:394
|
||||
#: src/components/PlaylistCreateForm.tsx:105
|
||||
#: src/pages/DumpEdit.tsx:288
|
||||
#: src/pages/PlaylistDetail.tsx:672
|
||||
#: src/pages/UserPublicProfile.tsx:700
|
||||
#: src/pages/UserPublicProfile.tsx:773
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:848
|
||||
msgid "Cancel removal"
|
||||
msgstr "Annuler la suppression"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:633
|
||||
msgid "Change avatar"
|
||||
msgstr "Changer l'avatar"
|
||||
|
||||
#: src/pages/UserRegister.tsx:94
|
||||
msgid "Checking invite…"
|
||||
msgstr "Vérification de l'invitation…"
|
||||
|
||||
#: src/components/Modal.tsx:45
|
||||
msgid "Close"
|
||||
msgstr "Fermer"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:81
|
||||
msgid "Copied!"
|
||||
msgstr "Copié !"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:81
|
||||
msgid "Copy"
|
||||
msgstr "Copier"
|
||||
|
||||
#: src/components/CommentThread.tsx:108
|
||||
#: src/components/CommentThread.tsx:147
|
||||
#: src/components/CommentThread.tsx:425
|
||||
msgid "Could not reach the server. Please try again."
|
||||
msgstr "Impossible de contacter le serveur. Veuillez réessayer."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:116
|
||||
msgid "Create"
|
||||
msgstr "Créer"
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:115
|
||||
msgid "Create & Add"
|
||||
msgstr "Créer et ajouter"
|
||||
|
||||
#. placeholder {0}: created.items.length
|
||||
#. placeholder {1}: created.hasMore ? "+" : ""
|
||||
#: src/pages/UserPlaylists.tsx:386
|
||||
msgid "Created ({0}{1})"
|
||||
msgstr "Créées ({0}{1})"
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:113
|
||||
msgid "Creating…"
|
||||
msgstr "Création…"
|
||||
|
||||
#: src/components/CommentThread.tsx:306
|
||||
#: src/components/CommentThread.tsx:312
|
||||
#: src/components/ConfirmModal.tsx:16
|
||||
#: src/pages/PlaylistDetail.tsx:679
|
||||
msgid "Delete"
|
||||
msgstr "Supprimer"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:284
|
||||
#: src/pages/DumpEdit.tsx:300
|
||||
msgid "Delete dump"
|
||||
msgstr "Supprimer la reco"
|
||||
|
||||
#: src/components/PlaylistCard.tsx:107
|
||||
#: src/pages/PlaylistDetail.tsx:861
|
||||
#: src/pages/UserPlaylists.tsx:443
|
||||
msgid "Delete playlist"
|
||||
msgstr "Supprimer la collection"
|
||||
|
||||
#: src/components/CommentThread.tsx:311
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Supprimer ce commentaire ?"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:299
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:860
|
||||
#: src/pages/UserPlaylists.tsx:442
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:76
|
||||
#: src/pages/PlaylistDetail.tsx:710
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (facultatif)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:439
|
||||
msgid "Done"
|
||||
msgstr "Terminé"
|
||||
|
||||
#: src/components/FileDropZone.tsx:32
|
||||
msgid "Drop a file here"
|
||||
msgstr "Déposez un fichier ici"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:242
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Déposez un fichier de remplacement ici"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:405
|
||||
msgid "Dump it"
|
||||
msgstr "Recommander"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:416
|
||||
msgid "Dumped!"
|
||||
msgstr "Recommandé !"
|
||||
|
||||
#: src/pages/Search.tsx:172
|
||||
#: src/pages/UserDumps.tsx:75
|
||||
msgid "Dumps"
|
||||
msgstr "Recos"
|
||||
|
||||
#. placeholder {0}: dumps.items.length
|
||||
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:817
|
||||
msgid "Dumps ({0}{1})"
|
||||
msgstr "Recos ({0}{1})"
|
||||
|
||||
#: src/pages/Notifications.tsx:341
|
||||
msgid "Earlier"
|
||||
msgstr "Plus tôt"
|
||||
|
||||
#: src/components/CommentThread.tsx:297
|
||||
#: src/pages/Dump.tsx:315
|
||||
#: src/pages/PlaylistDetail.tsx:698
|
||||
msgid "Edit"
|
||||
msgstr "Modifier"
|
||||
|
||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
||||
#: src/components/CommentThread.tsx:231
|
||||
#: src/pages/Dump.tsx:276
|
||||
#: src/pages/PlaylistDetail.tsx:768
|
||||
msgid "edited {0}"
|
||||
msgstr "modifié {0}"
|
||||
|
||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
||||
#: src/components/CommentThread.tsx:229
|
||||
#: src/pages/Dump.tsx:274
|
||||
#: src/pages/PlaylistDetail.tsx:765
|
||||
msgid "Edited {0}"
|
||||
msgstr "Modifié le {0}"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:180
|
||||
msgid "Editing"
|
||||
msgstr "Modification"
|
||||
|
||||
#: src/pages/UserRegister.tsx:135
|
||||
msgid "Email address"
|
||||
msgstr "Adresse e-mail"
|
||||
|
||||
#: src/pages/Search.tsx:206
|
||||
msgid "Enter a query to search."
|
||||
msgstr "Saisissez une recherche."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:59
|
||||
#: src/components/PlaylistCreateForm.tsx:97
|
||||
msgid "Failed to create playlist"
|
||||
msgstr "Impossible de créer la collection"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:62
|
||||
#: src/pages/UserPublicProfile.tsx:65
|
||||
#: src/pages/UserPublicProfile.tsx:92
|
||||
msgid "Failed to generate invite"
|
||||
msgstr "Impossible de générer une invitation"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:77
|
||||
#: src/pages/index/HotFeed.tsx:30
|
||||
#: src/pages/index/JournalFeed.tsx:42
|
||||
#: src/pages/index/NewFeed.tsx:30
|
||||
#: src/pages/Notifications.tsx:321
|
||||
msgid "Failed to load"
|
||||
msgstr "Chargement échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:300
|
||||
msgid "Failed to post"
|
||||
msgstr "Publication échouée"
|
||||
|
||||
#: src/components/CommentThread.tsx:462
|
||||
msgid "Failed to post comment"
|
||||
msgstr "Impossible de publier le commentaire"
|
||||
|
||||
#: src/components/CommentThread.tsx:334
|
||||
msgid "Failed to post reply"
|
||||
msgstr "Impossible de publier la réponse"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:776
|
||||
#: src/pages/UserPublicProfile.tsx:546
|
||||
#: src/pages/UserPublicProfile.tsx:581
|
||||
#: src/pages/UserPublicProfile.tsx:704
|
||||
#: src/pages/UserPublicProfile.tsx:776
|
||||
msgid "Failed to save"
|
||||
msgstr "Enregistrement échoué"
|
||||
|
||||
#: src/components/CommentThread.tsx:249
|
||||
msgid "Failed to save edit"
|
||||
msgstr "Impossible d'enregistrer la modification"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:726
|
||||
msgid "Failed to update avatar"
|
||||
msgstr "Impossible de mettre à jour l'avatar"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:333
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Récupération de l'aperçu…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:403
|
||||
msgid "Fetching…"
|
||||
msgstr "Récupération…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:293
|
||||
#: src/components/FileDropZone.tsx:31
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:187
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||
|
||||
#: src/components/FollowButton.tsx:37
|
||||
#: src/components/FollowButton.tsx:64
|
||||
msgid "Follow"
|
||||
msgstr "Suivre"
|
||||
|
||||
#: src/components/FollowButton.tsx:35
|
||||
msgid "Follow {targetUsername}"
|
||||
msgstr "Suivre {targetUsername}"
|
||||
|
||||
#: src/components/FollowButton.tsx:62
|
||||
msgid "Follow playlist"
|
||||
msgstr "Suivre la collection"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:359
|
||||
msgid "Follow some public playlists to see their dumps here."
|
||||
msgstr "Suivez des collections publiques pour voir leurs recos ici."
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:345
|
||||
msgid "Follow some users to see their dumps here."
|
||||
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||
|
||||
#: src/components/FeedTabBar.tsx:47
|
||||
msgid "Followed"
|
||||
msgstr "Suivi"
|
||||
|
||||
#. placeholder {0}: followed.items.length
|
||||
#. placeholder {1}: followed.hasMore ? "+" : ""
|
||||
#: src/pages/UserPlaylists.tsx:416
|
||||
msgid "Followed ({0}{1})"
|
||||
msgstr "Suivies ({0}{1})"
|
||||
|
||||
#: src/components/FollowButton.tsx:37
|
||||
#: src/components/FollowButton.tsx:64
|
||||
msgid "Following"
|
||||
msgstr "Abonné"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:325
|
||||
msgid "From people"
|
||||
msgstr "De personnes"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:332
|
||||
msgid "From playlists"
|
||||
msgstr "De collections"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:25
|
||||
msgid "Hot"
|
||||
msgstr "Tendances"
|
||||
|
||||
#: src/pages/UserRegister.tsx:104
|
||||
msgid "Invalid invite"
|
||||
msgstr "Invitation invalide"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:651
|
||||
msgid "invited by"
|
||||
msgstr "invité par"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:39
|
||||
msgid "Journal"
|
||||
msgstr "Journal"
|
||||
|
||||
#: src/pages/Notifications.tsx:178
|
||||
msgid "just now"
|
||||
msgstr "à l'instant"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:359
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||
|
||||
#: src/components/AppHeader.tsx:79
|
||||
msgid "Live updates unavailable."
|
||||
msgstr "Mises à jour en direct indisponibles."
|
||||
|
||||
#: src/pages/Notifications.tsx:386
|
||||
msgid "Load more"
|
||||
msgstr "Charger plus"
|
||||
|
||||
#: src/pages/Dump.tsx:193
|
||||
#: src/pages/DumpEdit.tsx:143
|
||||
msgid "Loading dump…"
|
||||
msgstr "Chargement de la reco…"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:103
|
||||
#: src/pages/index/HotFeed.tsx:52
|
||||
#: src/pages/index/JournalFeed.tsx:65
|
||||
#: src/pages/index/NewFeed.tsx:52
|
||||
#: src/pages/Search.tsx:239
|
||||
#: src/pages/UserDumps.tsx:111
|
||||
#: src/pages/UserPlaylists.tsx:409
|
||||
#: src/pages/UserPlaylists.tsx:436
|
||||
#: src/pages/UserUpvoted.tsx:180
|
||||
msgid "Loading more…"
|
||||
msgstr "Chargement…"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:590
|
||||
msgid "Loading playlist…"
|
||||
msgstr "Chargement de la collection…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:590
|
||||
msgid "Loading profile…"
|
||||
msgstr "Chargement du profil…"
|
||||
|
||||
#: src/components/PlaylistMembershipPanel.tsx:26
|
||||
#: src/components/TextEditor.tsx:273
|
||||
#: src/pages/index/FollowedFeed.tsx:74
|
||||
#: src/pages/index/HotFeed.tsx:29
|
||||
#: src/pages/index/JournalFeed.tsx:41
|
||||
#: src/pages/index/NewFeed.tsx:29
|
||||
#: src/pages/Notifications.tsx:318
|
||||
#: src/pages/Notifications.tsx:386
|
||||
#: src/pages/UserDumps.tsx:50
|
||||
#: src/pages/UserPlaylists.tsx:341
|
||||
#: src/pages/UserUpvoted.tsx:119
|
||||
msgid "Loading…"
|
||||
msgstr "Chargement…"
|
||||
|
||||
#: src/components/AppHeader.tsx:70
|
||||
#: src/pages/UserLogin.tsx:62
|
||||
#: src/pages/UserLogin.tsx:91
|
||||
msgid "Log in"
|
||||
msgstr "Se connecter"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:610
|
||||
#: src/pages/UserPublicProfile.tsx:738
|
||||
msgid "Log out"
|
||||
msgstr "Se déconnecter"
|
||||
|
||||
#: src/pages/UserLogin.tsx:90
|
||||
msgid "Logging in…"
|
||||
msgstr "Connexion…"
|
||||
|
||||
#: src/pages/UserLogin.tsx:65
|
||||
msgid "Login failed"
|
||||
msgstr "Connexion échouée"
|
||||
|
||||
#: src/components/FileDropZone.tsx:141
|
||||
msgid "Max 50 MB"
|
||||
msgstr "Max 50 Mo"
|
||||
|
||||
#: src/pages/Notifications.tsx:312
|
||||
msgid "new"
|
||||
msgstr "nouveau"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:32
|
||||
msgid "New"
|
||||
msgstr "Nouveau"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:262
|
||||
msgid "New dump"
|
||||
msgstr "Nouvelle reco"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:783
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "Aucune reco dans cette collection pour l'instant."
|
||||
|
||||
#: src/pages/Search.tsx:220
|
||||
msgid "No dumps match \"{q}\"."
|
||||
msgstr "Aucune reco ne correspond à « {q} »."
|
||||
|
||||
#: src/pages/index/HotFeed.tsx:32
|
||||
#: src/pages/index/JournalFeed.tsx:44
|
||||
#: src/pages/index/NewFeed.tsx:32
|
||||
msgid "No dumps yet. Be the first!"
|
||||
msgstr "Pas encore de recos. Soyez le premier !"
|
||||
|
||||
#: src/components/TextEditor.tsx:274
|
||||
msgid "No emoji found."
|
||||
msgstr "Aucun emoji trouvé."
|
||||
|
||||
#: src/pages/UserPlaylists.tsx:424
|
||||
msgid "No followed playlists yet."
|
||||
msgstr "Pas encore de collections suivies."
|
||||
|
||||
#: src/pages/Search.tsx:273
|
||||
msgid "No playlists match \"{q}\"."
|
||||
msgstr "Aucune collection ne correspond à « {q} »."
|
||||
|
||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||
#: src/pages/UserPlaylists.tsx:392
|
||||
#: src/pages/UserPublicProfile.tsx:865
|
||||
msgid "No playlists yet."
|
||||
msgstr "Pas encore de collections."
|
||||
|
||||
#: src/pages/Search.tsx:249
|
||||
msgid "No users match \"{q}\"."
|
||||
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||
|
||||
#: src/pages/Notifications.tsx:327
|
||||
#: src/pages/UserDumps.tsx:92
|
||||
#: src/pages/UserPublicProfile.tsx:930
|
||||
#: src/pages/UserPublicProfile.tsx:1049
|
||||
#: src/pages/UserUpvoted.tsx:151
|
||||
msgid "Nothing here yet."
|
||||
msgstr "Rien ici pour l'instant."
|
||||
|
||||
#: src/components/NotificationBell.tsx:42
|
||||
#: src/pages/Notifications.tsx:308
|
||||
msgid "Notifications"
|
||||
msgstr "Notifications"
|
||||
|
||||
#: src/components/NotificationBell.tsx:41
|
||||
msgid "Notifications ({unreadNotificationCount} unread)"
|
||||
msgstr "Notifications ({unreadNotificationCount} non lues)"
|
||||
|
||||
#: src/components/SearchBar.tsx:71
|
||||
msgid "Open search"
|
||||
msgstr "Ouvrir la recherche"
|
||||
|
||||
#: src/components/FileDropZone.tsx:139
|
||||
msgid "or <0>browse files</0>"
|
||||
msgstr "ou <0>parcourir les fichiers</0>"
|
||||
|
||||
#: src/pages/UserLogin.tsx:80
|
||||
msgid "Password"
|
||||
msgstr "Mot de passe"
|
||||
|
||||
#. placeholder {0}: VALIDATION.PASSWORD_MIN
|
||||
#: src/pages/UserRegister.tsx:142
|
||||
msgid "Password (min. {0} characters)"
|
||||
msgstr "Mot de passe (min. {0} caractères)"
|
||||
|
||||
#: src/components/AppHeader.tsx:46
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:175
|
||||
#: src/pages/UserPlaylists.tsx:366
|
||||
msgid "Playlists"
|
||||
msgstr "Collections"
|
||||
|
||||
#. placeholder {0}: playlists.items.length
|
||||
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:845
|
||||
msgid "Playlists ({0}{1})"
|
||||
msgstr "Collections ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:180
|
||||
msgid "Please select a file."
|
||||
msgstr "Veuillez sélectionner un fichier."
|
||||
|
||||
#: src/components/CommentThread.tsx:472
|
||||
msgid "Post comment"
|
||||
msgstr "Publier le commentaire"
|
||||
|
||||
#: src/components/CommentThread.tsx:342
|
||||
msgid "Post reply"
|
||||
msgstr "Publier la réponse"
|
||||
|
||||
#: src/components/CommentThread.tsx:342
|
||||
#: src/components/CommentThread.tsx:472
|
||||
msgid "Posting…"
|
||||
msgstr "Publication…"
|
||||
|
||||
#: src/components/DumpCard.tsx:91
|
||||
#: src/components/PlaylistCard.tsx:71
|
||||
#: src/components/PlaylistMembershipPanel.tsx:47
|
||||
#: src/pages/Dump.tsx:282
|
||||
#: src/pages/PlaylistDetail.tsx:748
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:383
|
||||
#: src/components/PlaylistCreateForm.tsx:94
|
||||
#: src/pages/DumpEdit.tsx:274
|
||||
#: src/pages/PlaylistDetail.tsx:737
|
||||
msgid "Private"
|
||||
msgstr "Privé"
|
||||
|
||||
#: src/components/PlaylistCard.tsx:71
|
||||
#: src/pages/PlaylistDetail.tsx:748
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:375
|
||||
#: src/components/PlaylistCreateForm.tsx:87
|
||||
#: src/pages/DumpEdit.tsx:267
|
||||
#: src/pages/PlaylistDetail.tsx:730
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:206
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Actualiser les métadonnées"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:206
|
||||
msgid "Refreshing…"
|
||||
msgstr "Actualisation…"
|
||||
|
||||
#: src/pages/UserRegister.tsx:115
|
||||
#: src/pages/UserRegister.tsx:155
|
||||
msgid "Register"
|
||||
msgstr "S'inscrire"
|
||||
|
||||
#: src/pages/UserRegister.tsx:154
|
||||
msgid "Registering…"
|
||||
msgstr "Inscription…"
|
||||
|
||||
#: src/pages/UserRegister.tsx:118
|
||||
msgid "Registration failed"
|
||||
msgstr "Inscription échouée"
|
||||
|
||||
#: src/components/FileDropZone.tsx:115
|
||||
msgid "Remove file"
|
||||
msgstr "Supprimer le fichier"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:838
|
||||
msgid "Remove from playlist"
|
||||
msgstr "Retirer de la collection"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:241
|
||||
msgid "Replace file"
|
||||
msgstr "Remplacer le fichier"
|
||||
|
||||
#: src/components/CommentThread.tsx:284
|
||||
msgid "Reply"
|
||||
msgstr "Répondre"
|
||||
|
||||
#: src/pages/Dump.tsx:209
|
||||
#: src/pages/DumpEdit.tsx:159
|
||||
msgid "Retry"
|
||||
msgstr "Réessayer"
|
||||
|
||||
#: src/components/CommentThread.tsx:257
|
||||
#: src/pages/DumpEdit.tsx:291
|
||||
#: src/pages/PlaylistDetail.tsx:665
|
||||
#: src/pages/UserPublicProfile.tsx:692
|
||||
#: src/pages/UserPublicProfile.tsx:765
|
||||
msgid "Save"
|
||||
msgstr "Enregistrer"
|
||||
|
||||
#: src/components/CommentThread.tsx:257
|
||||
#: src/pages/PlaylistDetail.tsx:665
|
||||
#: src/pages/UserPublicProfile.tsx:692
|
||||
#: src/pages/UserPublicProfile.tsx:765
|
||||
msgid "Saving…"
|
||||
msgstr "Enregistrement…"
|
||||
|
||||
#: src/components/SearchBar.tsx:65
|
||||
msgid "Search"
|
||||
msgstr "Rechercher"
|
||||
|
||||
#: src/components/SearchBar.tsx:61
|
||||
msgid "Search dumps, users, playlists…"
|
||||
msgstr "Rechercher des recos, utilisateurs, collections…"
|
||||
|
||||
#: src/pages/Search.tsx:214
|
||||
msgid "Search failed"
|
||||
msgstr "Recherche échouée"
|
||||
|
||||
#: src/pages/Search.tsx:210
|
||||
msgid "Searching…"
|
||||
msgstr "Recherche…"
|
||||
|
||||
#: src/components/AppHeader.tsx:61
|
||||
msgid "Server unreachable"
|
||||
msgstr "Serveur inaccessible"
|
||||
|
||||
#: src/components/PageError.tsx:13
|
||||
msgid "Something went wrong"
|
||||
msgstr "Une erreur est survenue"
|
||||
|
||||
#: src/components/SearchBar.tsx:71
|
||||
msgid "Submit search"
|
||||
msgstr "Lancer la recherche"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:755
|
||||
msgid "Tell people about yourself…"
|
||||
msgstr "Parlez de vous…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:363
|
||||
#: src/pages/DumpEdit.tsx:256
|
||||
msgid "Tell the community what makes this worth their time..."
|
||||
msgstr "Dites à la communauté pourquoi ça vaut le coup…"
|
||||
|
||||
#: src/pages/UserRegister.tsx:105
|
||||
msgid "This invite link is missing, expired, or already used."
|
||||
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
||||
|
||||
#: src/pages/UserLogin.tsx:96
|
||||
msgid "This is a mirage."
|
||||
msgstr "C'est un mirage."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:69
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: src/pages/Notifications.tsx:341
|
||||
msgid "Today"
|
||||
msgstr "Aujourd'hui"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:850
|
||||
msgid "Undo"
|
||||
msgstr "Annuler"
|
||||
|
||||
#: src/components/FollowButton.tsx:34
|
||||
msgid "Unfollow {targetUsername}"
|
||||
msgstr "Ne plus suivre {targetUsername}"
|
||||
|
||||
#: src/components/FollowButton.tsx:62
|
||||
msgid "Unfollow playlist"
|
||||
msgstr "Ne plus suivre la collection"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:515
|
||||
msgid "Upload failed"
|
||||
msgstr "Envoi échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:404
|
||||
msgid "Uploading…"
|
||||
msgstr "Envoi…"
|
||||
|
||||
#: src/pages/UserUpvoted.tsx:147
|
||||
msgid "Upvoted"
|
||||
msgstr "Voté"
|
||||
|
||||
#. placeholder {0}: votes.items.length
|
||||
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:829
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Votés ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:309
|
||||
#: src/pages/DumpEdit.tsx:221
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:164
|
||||
msgid "URL is required."
|
||||
msgstr "L'URL est obligatoire."
|
||||
|
||||
#: src/components/UserMenu.tsx:37
|
||||
msgid "User menu"
|
||||
msgstr "Menu utilisateur"
|
||||
|
||||
#: src/pages/UserLogin.tsx:72
|
||||
#: src/pages/UserRegister.tsx:125
|
||||
msgid "Username"
|
||||
msgstr "Nom d'utilisateur"
|
||||
|
||||
#: src/pages/Search.tsx:174
|
||||
msgid "Users"
|
||||
msgstr "Utilisateurs"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:878
|
||||
#: src/pages/UserPublicProfile.tsx:948
|
||||
#: src/pages/UserPublicProfile.tsx:1076
|
||||
msgid "View all →"
|
||||
msgstr "Tout voir →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:418
|
||||
msgid "View dump →"
|
||||
msgstr "Voir la reco →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:356
|
||||
#: src/pages/DumpEdit.tsx:250
|
||||
msgid "Why are you dumping this?"
|
||||
msgstr "Pourquoi recommandez-vous ça ?"
|
||||
|
||||
#: src/components/CommentThread.tsx:329
|
||||
msgid "Write a reply…"
|
||||
msgstr "Écrire une réponse…"
|
||||
|
||||
#: src/pages/Notifications.tsx:341
|
||||
msgid "Yesterday"
|
||||
msgstr "Hier"
|
||||
|
||||
#: src/pages/Notifications.tsx:329
|
||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
||||
|
||||
#: src/pages/index/HotFeed.tsx:54
|
||||
#: src/pages/index/JournalFeed.tsx:67
|
||||
#: src/pages/index/NewFeed.tsx:54
|
||||
#: src/pages/Search.tsx:242
|
||||
msgid "You've reached the end."
|
||||
msgstr "Vous avez tout lu, tout vu, tout bu."
|
||||
@@ -1,12 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { 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>,
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
44
src/utils/waveform.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const NUM_BARS = 150;
|
||||
export const BAR_W = 3;
|
||||
export const BAR_GAP = 1;
|
||||
export const WAVEFORM_H = 48;
|
||||
export const VIEWBOX_W = NUM_BARS * (BAR_W + BAR_GAP); // 600
|
||||
|
||||
// Module-level cache: survives StrictMode double-effect and re-renders
|
||||
const peaksCache = new Map<string, Float32Array>();
|
||||
|
||||
export async function extractPeaks(
|
||||
url: string,
|
||||
n: number,
|
||||
): Promise<Float32Array> {
|
||||
if (peaksCache.has(url)) return peaksCache.get(url)!;
|
||||
|
||||
// /api/files is public (no auth middleware). Plain fetch avoids the CORS
|
||||
// preflight that credentials/Authorization would trigger — the server's
|
||||
// oakCors config doesn't set Allow-Credentials so credentialed fetches
|
||||
// are blocked, while <audio> bypasses CORS entirely.
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
|
||||
const actx = new AudioContext();
|
||||
try {
|
||||
const decoded = await actx.decodeAudioData(buf);
|
||||
const data = decoded.getChannelData(0);
|
||||
const blockSize = Math.floor(data.length / n);
|
||||
const peaks = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
let peak = 0;
|
||||
const offset = i * blockSize;
|
||||
for (let j = 0; j < blockSize; j++) {
|
||||
const abs = Math.abs(data[offset + j]);
|
||||
if (abs > peak) peak = abs;
|
||||
}
|
||||
peaks[i] = peak;
|
||||
}
|
||||
peaksCache.set(url, peaks);
|
||||
return peaks;
|
||||
} finally {
|
||||
actx.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user