v3: added site-wide categories, added admin category management, various visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
This commit is contained in:
390
src/App.css
390
src/App.css
@@ -1570,12 +1570,36 @@ body.has-fab .page-content {
|
||||
}
|
||||
|
||||
/* ── Profile (own) page ── */
|
||||
.profile-username-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Role badge — elevated roles only (see RoleChip) */
|
||||
.profile-role-chip {
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-role-chip--moderator {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.profile-header .btn-border {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@@ -3840,30 +3864,46 @@ body.has-fab .page-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Child list: indented, left edge aligns with parent avatar center */
|
||||
/* Child list: indented so connector lines have room on the left.
|
||||
The vertical line sits at the list's left:0, centered under the parent
|
||||
avatar (avatar center 12px - 1px half-line = 11px). */
|
||||
.invite-tree-node > .invite-tree {
|
||||
position: relative;
|
||||
margin-left: 11px; /* center 2px line on 24px avatar: 12px - 1px */
|
||||
margin-left: 11px;
|
||||
padding-left: calc(12px + 0.5rem);
|
||||
}
|
||||
|
||||
/* Vertical connector: starts below parent avatar, stops at last child avatar center */
|
||||
.invite-tree-node > .invite-tree::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 12px;
|
||||
width: 2px;
|
||||
background: var(--color-border-subtle);
|
||||
/* Connectors are owned per child node (not by the list) so they always anchor
|
||||
to that child's own avatar center, regardless of how deep its subtree is.
|
||||
--avatar-center = node top padding (0.2rem) + half the 24px avatar (12px). */
|
||||
.invite-tree-node > .invite-tree > .invite-tree-node {
|
||||
--avatar-center: calc(0.2rem + 12px);
|
||||
}
|
||||
|
||||
/* Horizontal connector: from vertical line to each child's avatar left edge */
|
||||
/* Vertical segment: drops from the top of the child node to its avatar center,
|
||||
joining the line coming down from the parent / previous sibling. */
|
||||
.invite-tree-node > .invite-tree > .invite-tree-node::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(-12px - 0.5rem);
|
||||
top: calc(0.2rem + 11px); /* centered on 24px avatar */
|
||||
top: 0;
|
||||
height: var(--avatar-center);
|
||||
width: 2px;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* Non-last children extend the vertical segment through their full height
|
||||
(including any subtree) so it reaches the next sibling. */
|
||||
.invite-tree-node > .invite-tree > .invite-tree-node:not(:last-child)::before {
|
||||
height: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Horizontal connector: from the vertical line to the child's avatar. */
|
||||
.invite-tree-node > .invite-tree > .invite-tree-node::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(-12px - 0.5rem);
|
||||
top: calc(var(--avatar-center) - 1px); /* straddle center with the 2px bar */
|
||||
width: calc(12px + 0.5rem);
|
||||
height: 2px;
|
||||
background: var(--color-border-subtle);
|
||||
@@ -4836,3 +4876,325 @@ body.has-fab .page-content {
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Categories ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Scope switcher — the leading item of the feed-sort tab row. The trigger
|
||||
reuses .feed-sort-btn so it shares the tabs' exact pill metrics; a thin
|
||||
divider sets it apart from the sort tabs that follow. */
|
||||
.category-switcher {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-switcher-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.category-switcher-btn:hover,
|
||||
.category-switcher-btn--open {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.category-switcher-label {
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Caret tracks the open state. */
|
||||
.category-switcher-caret {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 0.26rem solid transparent;
|
||||
border-right: 0.26rem solid transparent;
|
||||
border-top: 0.3rem solid currentColor;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.category-switcher-btn--open .category-switcher-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Vertical rule separating the scope selector from the sort tabs. */
|
||||
.feed-sort-sep {
|
||||
width: 2px;
|
||||
height: 1.05rem;
|
||||
background: var(--color-border);
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Themed popover (fixed-positioned so the row's overflow can't clip it). */
|
||||
.category-switcher-menu {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
min-width: 11rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0.35rem;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border-subtle);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.category-switcher-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.category-switcher-option:hover {
|
||||
background: var(--color-header-user-bg-hover);
|
||||
}
|
||||
|
||||
.category-switcher-option--active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
/* Toggle chips used in the dump create/edit forms */
|
||||
.category-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.category-chip-toggle {
|
||||
padding: 0.25rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-chip-toggle:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.category-chip-toggle--active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
/* Read-only category chips on dump cards */
|
||||
.dump-card-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.dump-card-category-chip {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-header-user-bg);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.dump-card-category-chip:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
/* Admin category management — chips that expand into an inline editor */
|
||||
.category-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.category-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.category-chip:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.category-chip:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.category-chip:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Inherits the base chip's font-size/padding so its height matches exactly;
|
||||
only the glyph weight/color set it apart. */
|
||||
.category-chip--add {
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.category-chip--add:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
/* Inline editor: the expanded form of a chip */
|
||||
.category-editor-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-editor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.3rem 0.2rem 0.7rem;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
transform-origin: left center;
|
||||
animation: category-editor-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes category-editor-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.category-editor-input {
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
padding: 0.2rem 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.category-editor-input--name {
|
||||
width: 8.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.category-editor-input--slug {
|
||||
width: 6.5rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.category-editor-sep {
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.category-editor-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.category-editor-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.category-editor-btn:hover:not(:disabled) {
|
||||
background: var(--color-header-user-bg);
|
||||
}
|
||||
|
||||
.category-editor-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.category-editor-btn--save {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.category-editor-btn--save:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.category-editor-btn--save:disabled {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.category-editor-btn--delete:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-danger, #c0392b) 18%, transparent);
|
||||
color: var(--color-danger, #c0392b);
|
||||
}
|
||||
|
||||
46
src/App.tsx
46
src/App.tsx
@@ -1,5 +1,11 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useParams,
|
||||
} from "react-router";
|
||||
|
||||
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
|
||||
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
|
||||
@@ -10,6 +16,7 @@ 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 { CategoriesProvider } from "./contexts/CategoriesProvider.tsx";
|
||||
import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
|
||||
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||
import { DumpFab } from "./components/DumpFab.tsx";
|
||||
@@ -69,12 +76,29 @@ const NotFound = lazy(() =>
|
||||
import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound }))
|
||||
);
|
||||
|
||||
function IndexRedirect() {
|
||||
// Default feed tab for the current viewer ("followed" needs a session).
|
||||
function useResolvedDefaultTab() {
|
||||
const { user } = useAuth();
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
// "followed" requires a session, so fall back to "hot" for guests.
|
||||
const tab = preferredTab === "followed" && !user ? "hot" : preferredTab;
|
||||
return <Navigate to={`/~/${tab}`} replace />;
|
||||
return preferredTab === "followed" && !user ? "hot" : preferredTab;
|
||||
}
|
||||
|
||||
function IndexRedirect() {
|
||||
return <Navigate to={`/~/${useResolvedDefaultTab()}`} replace />;
|
||||
}
|
||||
|
||||
// Bare `/<slug>` lands on that category's default feed tab.
|
||||
function CategoryRedirect() {
|
||||
const { categorySlug } = useParams();
|
||||
return <Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}`} replace />;
|
||||
}
|
||||
|
||||
// Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed.
|
||||
// Keying by scope remounts the feed when switching categories so it refetches,
|
||||
// while switching tabs within a scope keeps it mounted (client-side re-sort).
|
||||
function ScopedIndex() {
|
||||
const { categorySlug } = useParams();
|
||||
return <Index key={categorySlug ?? "~"} categorySlug={categorySlug} />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
@@ -83,7 +107,7 @@ function AppRoutes() {
|
||||
<Suspense>
|
||||
<Routes>
|
||||
<Route path="/" element={<IndexRedirect />} />
|
||||
<Route path="/~/:feedTab" element={<Index />} />
|
||||
<Route path="/~/:feedTab" element={<ScopedIndex />} />
|
||||
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
||||
<Route
|
||||
path="/dumps/:selectedDump/edit"
|
||||
@@ -132,6 +156,8 @@ function AppRoutes() {
|
||||
</RestrictedLoggedIn>
|
||||
}
|
||||
/>
|
||||
<Route path="/:categorySlug/:feedTab" element={<ScopedIndex />} />
|
||||
<Route path="/:categorySlug" element={<CategoryRedirect />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -147,9 +173,11 @@ function App() {
|
||||
<PlayerProvider>
|
||||
<WSProvider>
|
||||
<FollowProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
<CategoriesProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</CategoriesProvider>
|
||||
</FollowProvider>
|
||||
</WSProvider>
|
||||
<GlobalPlayer />
|
||||
|
||||
BIN
src/components/CategoryManager.tsx
Normal file
BIN
src/components/CategoryManager.tsx
Normal file
Binary file not shown.
45
src/components/CategorySelect.tsx
Normal file
45
src/components/CategorySelect.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
|
||||
/**
|
||||
* Multi-select of categories rendered as toggle chips. Renders nothing when no
|
||||
* categories are configured, so dump forms stay unchanged until an admin adds
|
||||
* the first category.
|
||||
*/
|
||||
export function CategorySelect(
|
||||
{ selected, onToggle, disabled }: {
|
||||
selected: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
},
|
||||
) {
|
||||
const { categories } = useCategories();
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<span className="form-label">
|
||||
<Trans>Categories</Trans>
|
||||
</span>
|
||||
<div className="category-select">
|
||||
{categories.map((category) => {
|
||||
const isActive = selected.has(category.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={category.id}
|
||||
className={`category-chip-toggle${
|
||||
isActive ? " category-chip-toggle--active" : ""
|
||||
}`}
|
||||
onClick={() => onToggle(category.id)}
|
||||
disabled={disabled}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/components/CategorySwitcher.tsx
Normal file
137
src/components/CategorySwitcher.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
|
||||
/**
|
||||
* Header dropdown that switches the feed scope between "All" and a category,
|
||||
* preserving the active feed tab (e.g. /music/new → /cinema/new). Rendered as
|
||||
* the leading item of the feed-sort tab row so it reads as part of the bar.
|
||||
*
|
||||
* A custom popover (not a native <select>) keeps it on-theme; the menu uses
|
||||
* fixed positioning so the row's `overflow-x: auto` can't clip it. `~` is the
|
||||
* sentinel value for the all-categories feed. Hidden when no categories exist.
|
||||
*/
|
||||
export function CategorySwitcher() {
|
||||
const { categories } = useCategories();
|
||||
const navigate = useNavigate();
|
||||
const { categorySlug, feedTab } = useParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Close on outside click / Escape. The menu is portaled out of the wrapper,
|
||||
// so check it separately or selecting an option would close before navigating.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
wrapRef.current && !wrapRef.current.contains(target) &&
|
||||
menuRef.current && !menuRef.current.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Anchor the fixed-position menu to the trigger; keep it aligned on scroll.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const update = () => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (r) setMenuStyle({ top: r.bottom + 6, left: r.left });
|
||||
};
|
||||
update();
|
||||
globalThis.addEventListener("scroll", update, true);
|
||||
globalThis.addEventListener("resize", update);
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", update, true);
|
||||
globalThis.removeEventListener("resize", update);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
const value = categorySlug ?? "~";
|
||||
const tab = feedTab ?? "hot";
|
||||
const currentLabel = categorySlug
|
||||
? categories.find((c) => c.slug === categorySlug)?.name ?? categorySlug
|
||||
: t`All`;
|
||||
|
||||
const options = [
|
||||
{ value: "~", label: t`All` },
|
||||
...categories.map((c) => ({ value: c.slug, label: c.name })),
|
||||
];
|
||||
|
||||
function select(slug: string) {
|
||||
setOpen(false);
|
||||
navigate(`/${slug}/${tab}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-switcher" ref={wrapRef}>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className={`feed-sort-btn category-switcher-btn${
|
||||
open ? " category-switcher-btn--open" : ""
|
||||
}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={t`Category`}
|
||||
>
|
||||
<span className="category-switcher-label">{currentLabel}</span>
|
||||
<span className="category-switcher-caret" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<ul
|
||||
ref={menuRef}
|
||||
className="category-switcher-menu"
|
||||
role="listbox"
|
||||
style={menuStyle}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<li key={o.value}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === o.value}
|
||||
className={`category-switcher-option${
|
||||
value === o.value ? " category-switcher-option--active" : ""
|
||||
}`}
|
||||
onClick={() => select(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<span className="feed-sort-sep" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { Dump } from "../model.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
import FilePreview from "./FilePreview.tsx";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
@@ -28,6 +29,10 @@ export function DumpCard(
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuth();
|
||||
const { byId } = useCategories();
|
||||
const dumpCategories = (dump.categoryIds ?? [])
|
||||
.map((id) => byId.get(id))
|
||||
.filter((c) => c !== undefined);
|
||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||
!isDumpVisited(dump.id);
|
||||
|
||||
@@ -115,6 +120,22 @@ export function DumpCard(
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{dumpCategories.length > 0 && (
|
||||
<div
|
||||
className="dump-card-categories"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{dumpCategories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
to={`/${category.slug}`}
|
||||
className="dump-card-category-chip"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { CategorySelect } from "./CategorySelect.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
@@ -117,6 +118,16 @@ export function DumpCreateModal(
|
||||
const url = watch("url");
|
||||
const file = watch("file");
|
||||
|
||||
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const toggleCategory = (id: string) =>
|
||||
setSelectedCategoryIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Playlist phase state
|
||||
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||
@@ -219,6 +230,7 @@ export function DumpCreateModal(
|
||||
url: normalizedUrl,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
categoryIds: [...selectedCategoryIds],
|
||||
};
|
||||
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
@@ -235,6 +247,7 @@ export function DumpCreateModal(
|
||||
if (comment.trim()) formData.append("comment", comment.trim());
|
||||
if (title.trim()) formData.append("title", title.trim());
|
||||
formData.append("isPrivate", String(isPrivate));
|
||||
formData.append("categoryIds", JSON.stringify([...selectedCategoryIds]));
|
||||
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
@@ -435,6 +448,12 @@ export function DumpCreateModal(
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<CategorySelect
|
||||
selected={selectedCategoryIds}
|
||||
onToggle={toggleCategory}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<VisibilityToggle<CreateValues>
|
||||
name="isPublic"
|
||||
disabled={submitting}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
|
||||
import { useTabParam } from "../hooks/useTabParam.ts";
|
||||
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
||||
import { TabBar } from "./TabBar.tsx";
|
||||
import { CategorySwitcher } from "./CategorySwitcher.tsx";
|
||||
|
||||
/**
|
||||
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the
|
||||
* current scope: `~` for all categories, or the active category slug — so the
|
||||
* tabs link to `/~/new`, `/music/new`, etc.
|
||||
*/
|
||||
export function FeedTabBar() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { categorySlug, feedTab } = useParams();
|
||||
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? "hot"
|
||||
: preferredTab;
|
||||
const [tab, setTab] = useTabParam<FeedTab>(FEED_TABS, defaultTab);
|
||||
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "")
|
||||
? (feedTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
const scope = categorySlug ?? "~";
|
||||
|
||||
const tabs = [
|
||||
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
|
||||
@@ -25,8 +38,9 @@ export function FeedTabBar() {
|
||||
return (
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTab={tab}
|
||||
onChange={setTab}
|
||||
activeTab={active}
|
||||
onChange={(t) => navigate(`/${scope}/${t}`)}
|
||||
leading={<CategorySwitcher />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { dumpFileUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts";
|
||||
@@ -44,6 +44,8 @@ export function JournalCard(
|
||||
const rawThumbnail =
|
||||
dump.kind === "file" && dump.fileMime?.startsWith("image/")
|
||||
? dumpFileUrl(dump, token)
|
||||
: dump.thumbnailMime
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
: (dump.richContent?.thumbnailUrl ?? null);
|
||||
|
||||
// Route external HTTP thumbnails through the server proxy to avoid
|
||||
|
||||
@@ -11,14 +11,19 @@ interface TabBarProps<T extends string> {
|
||||
onChange: (key: T) => void;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
// Optional content rendered inside the row, before the tabs (e.g. a scope
|
||||
// selector). Sits in the same flex line so it scrolls and aligns with them.
|
||||
leading?: ReactNode;
|
||||
}
|
||||
|
||||
export function TabBar<T extends string>(
|
||||
{ tabs, activeTab, onChange, className, innerClassName }: TabBarProps<T>,
|
||||
{ tabs, activeTab, onChange, className, innerClassName, leading }:
|
||||
TabBarProps<T>,
|
||||
) {
|
||||
return (
|
||||
<div className={`feed-sort-scroller${className ? ` ${className}` : ""}`}>
|
||||
<div className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}>
|
||||
{leading}
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
||||
@@ -38,4 +38,6 @@ export const VALIDATION = {
|
||||
PLAYLIST_DESCRIPTION_MAX: 2000,
|
||||
COMMENT_BODY_MAX: 5000,
|
||||
USER_DESCRIPTION_MAX: 2000,
|
||||
CATEGORY_NAME_MAX: 50,
|
||||
CATEGORY_SLUG_MAX: 50,
|
||||
} as const;
|
||||
|
||||
18
src/contexts/CategoriesContext.ts
Normal file
18
src/contexts/CategoriesContext.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createContext } from "react";
|
||||
import type { Category } from "../model.ts";
|
||||
|
||||
export interface CategoriesContextValue {
|
||||
categories: Category[];
|
||||
// Lookup by id for resolving dump category chips.
|
||||
byId: Map<string, Category>;
|
||||
isLoaded: boolean;
|
||||
// Re-fetch the list (used after admin create/update/delete).
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const CategoriesContext = createContext<CategoriesContextValue>({
|
||||
categories: [],
|
||||
byId: new Map(),
|
||||
isLoaded: false,
|
||||
refresh: async () => {},
|
||||
});
|
||||
53
src/contexts/CategoriesProvider.tsx
Normal file
53
src/contexts/CategoriesProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CategoriesContext,
|
||||
type CategoriesContextValue,
|
||||
} from "./CategoriesContext.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import {
|
||||
type Category,
|
||||
deserializeCategory,
|
||||
type RawCategory,
|
||||
} from "../model.ts";
|
||||
|
||||
export function CategoriesProvider({ children }: { children: ReactNode }) {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/categories`, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
if (!body.success) return;
|
||||
setCategories((body.data as RawCategory[]).map(deserializeCategory));
|
||||
setIsLoaded(true);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") setIsLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(() => load(), [load]);
|
||||
|
||||
const byId = useMemo(
|
||||
() => new Map(categories.map((c) => [c.id, c])),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const value: CategoriesContextValue = useMemo(
|
||||
() => ({ categories, byId, isLoaded, refresh }),
|
||||
[categories, byId, isLoaded, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<CategoriesContext.Provider value={value}>
|
||||
{children}
|
||||
</CategoriesContext.Provider>
|
||||
);
|
||||
}
|
||||
4
src/hooks/useCategories.ts
Normal file
4
src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { CategoriesContext } from "../contexts/CategoriesContext.ts";
|
||||
|
||||
export const useCategories = () => useContext(CategoriesContext);
|
||||
File diff suppressed because one or more lines are too long
@@ -18,8 +18,8 @@ msgid "[deleted]"
|
||||
msgstr "[deleted]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: src/components/DumpCard.tsx:105
|
||||
#: src/components/JournalCard.tsx:110
|
||||
#: src/components/DumpCard.tsx:110
|
||||
#: src/components/JournalCard.tsx:112
|
||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||
|
||||
@@ -43,13 +43,13 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:570
|
||||
#: src/pages/UserPublicProfile.tsx:741
|
||||
#: src/pages/UserPublicProfile.tsx:749
|
||||
msgid "← Back"
|
||||
msgstr "← Back"
|
||||
|
||||
#: src/pages/Dump.tsx:259
|
||||
#: src/pages/Dump.tsx:472
|
||||
#: src/pages/DumpEdit.tsx:178
|
||||
#: src/pages/Dump.tsx:261
|
||||
#: src/pages/Dump.tsx:490
|
||||
#: src/pages/DumpEdit.tsx:179
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Back to all dumps"
|
||||
|
||||
@@ -59,7 +59,7 @@ msgstr "← Back to all dumps"
|
||||
msgid "← Back to profile"
|
||||
msgstr "← Back to profile"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:114
|
||||
#: src/pages/UserPublicProfile.tsx:115
|
||||
msgid "+ Invite someone"
|
||||
msgstr "+ Invite someone"
|
||||
|
||||
@@ -67,7 +67,7 @@ msgstr "+ Invite someone"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ New playlist"
|
||||
|
||||
#: src/pages/Dump.tsx:327
|
||||
#: src/pages/Dump.tsx:332
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Playlist"
|
||||
|
||||
@@ -125,7 +125,7 @@ msgstr "a comment"
|
||||
msgid "a post"
|
||||
msgstr "a post"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1120
|
||||
#: src/pages/UserPublicProfile.tsx:1131
|
||||
msgid "Account"
|
||||
msgstr "Account"
|
||||
|
||||
@@ -133,24 +133,29 @@ msgstr "Account"
|
||||
msgid "Add a comment…"
|
||||
msgstr "Add a comment…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:816
|
||||
#: src/pages/UserPublicProfile.tsx:827
|
||||
msgid "Add email…"
|
||||
msgstr "Add email…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:292
|
||||
#: src/components/DumpCreateModal.tsx:305
|
||||
msgid "Add to playlist"
|
||||
msgstr "Add to playlist"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1529
|
||||
#: src/pages/UserPublicProfile.tsx:1549
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
|
||||
#: src/components/CategorySwitcher.tsx:79
|
||||
#: src/components/CategorySwitcher.tsx:82
|
||||
msgid "All"
|
||||
msgstr "All"
|
||||
|
||||
#: src/pages/UserRegister.tsx:156
|
||||
msgid "Already have an account? <0>Log in</0>"
|
||||
msgstr "Already have an account? <0>Log in</0>"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1139
|
||||
#: src/pages/UserPublicProfile.tsx:1150
|
||||
msgid "Appearance"
|
||||
msgstr "Appearance"
|
||||
|
||||
@@ -161,7 +166,7 @@ msgstr "Appearance"
|
||||
msgid "At least {0} characters"
|
||||
msgstr "At least {0} characters"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1187
|
||||
#: src/pages/UserPublicProfile.tsx:1198
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
@@ -178,11 +183,11 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
|
||||
#: src/components/CommentThread.tsx:124
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:368
|
||||
#: src/pages/DumpEdit.tsx:443
|
||||
#: src/pages/Dump.tsx:373
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/PlaylistDetail.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:1646
|
||||
#: src/pages/UserPublicProfile.tsx:1716
|
||||
#: src/pages/UserPublicProfile.tsx:1674
|
||||
#: src/pages/UserPublicProfile.tsx:1744
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
@@ -194,7 +199,16 @@ msgstr "Cancel removal"
|
||||
msgid "Cancel search"
|
||||
msgstr "Cancel search"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:768
|
||||
#: src/components/CategorySelect.tsx:22
|
||||
#: src/pages/UserPublicProfile.tsx:1281
|
||||
msgid "Categories"
|
||||
msgstr "Categories"
|
||||
|
||||
#: src/components/CategorySwitcher.tsx:102
|
||||
msgid "Category"
|
||||
msgstr "Category"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:776
|
||||
msgid "Change avatar"
|
||||
msgstr "Change avatar"
|
||||
|
||||
@@ -203,7 +217,7 @@ msgstr "Change avatar"
|
||||
msgid "Change password"
|
||||
msgstr "Change password"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1132
|
||||
#: src/pages/UserPublicProfile.tsx:1143
|
||||
msgid "Change password…"
|
||||
msgstr "Change password…"
|
||||
|
||||
@@ -216,7 +230,7 @@ msgstr "Checking invite…"
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1179
|
||||
#: src/pages/UserPublicProfile.tsx:1190
|
||||
msgid "Color scheme"
|
||||
msgstr "Color scheme"
|
||||
|
||||
@@ -225,11 +239,11 @@ msgstr "Color scheme"
|
||||
msgid "Confirm new password"
|
||||
msgstr "Confirm new password"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:105
|
||||
#: src/pages/UserPublicProfile.tsx:106
|
||||
msgid "Copied!"
|
||||
msgstr "Copied!"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:105
|
||||
#: src/pages/UserPublicProfile.tsx:106
|
||||
msgid "Copy"
|
||||
msgstr "Copy"
|
||||
|
||||
@@ -241,10 +255,11 @@ msgstr "Could not change password"
|
||||
msgid "Could not load."
|
||||
msgstr "Could not load."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:344
|
||||
#: src/pages/DumpEdit.tsx:359
|
||||
msgid "Could not save"
|
||||
msgstr "Could not save"
|
||||
|
||||
#: src/components/CategoryManager.tsx:106
|
||||
#: src/components/PlaylistCreateForm.tsx:87
|
||||
msgid "Create"
|
||||
msgstr "Create"
|
||||
@@ -259,6 +274,7 @@ msgstr "Create & Add"
|
||||
msgid "Created ({0}{1})"
|
||||
msgstr "Created ({0}{1})"
|
||||
|
||||
#: src/components/CategoryManager.tsx:103
|
||||
#: src/components/PlaylistCreateForm.tsx:86
|
||||
msgid "Creating…"
|
||||
msgstr "Creating…"
|
||||
@@ -267,14 +283,16 @@ msgstr "Creating…"
|
||||
msgid "Current password"
|
||||
msgstr "Current password"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1201
|
||||
#: src/pages/UserPublicProfile.tsx:1212
|
||||
msgid "Dark"
|
||||
msgstr "Dark"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1214
|
||||
#: src/pages/UserPublicProfile.tsx:1225
|
||||
msgid "Default tab"
|
||||
msgstr "Default tab"
|
||||
|
||||
#: src/components/CategoryManager.tsx:191
|
||||
#: src/components/CategoryManager.tsx:192
|
||||
#: src/components/CommentThread.tsx:376
|
||||
#: src/components/CommentThread.tsx:382
|
||||
#: src/components/ConfirmModal.tsx:16
|
||||
@@ -282,8 +300,17 @@ msgstr "Default tab"
|
||||
msgid "Delete"
|
||||
msgstr "Delete"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:252
|
||||
#: src/pages/DumpEdit.tsx:439
|
||||
#: src/components/CategoryManager.tsx:202
|
||||
msgid "Delete category"
|
||||
msgstr "Delete category"
|
||||
|
||||
#. placeholder {0}: category.name
|
||||
#: src/components/CategoryManager.tsx:201
|
||||
msgid "Delete category \"{0}\"? This cannot be undone."
|
||||
msgstr "Delete category \"{0}\"? This cannot be undone."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:253
|
||||
#: src/pages/DumpEdit.tsx:459
|
||||
msgid "Delete dump"
|
||||
msgstr "Delete dump"
|
||||
|
||||
@@ -297,7 +324,7 @@ msgstr "Delete playlist"
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Delete this comment?"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:251
|
||||
#: src/pages/DumpEdit.tsx:252
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Delete this dump? This cannot be undone."
|
||||
|
||||
@@ -311,7 +338,7 @@ msgstr "Delete this playlist? This cannot be undone."
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (optional)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:479
|
||||
#: src/components/DumpCreateModal.tsx:498
|
||||
msgid "Done"
|
||||
msgstr "Done"
|
||||
|
||||
@@ -319,7 +346,7 @@ msgstr "Done"
|
||||
msgid "Drop a file here"
|
||||
msgstr "Drop a file here"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:414
|
||||
#: src/pages/DumpEdit.tsx:429
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Drop a replacement here"
|
||||
|
||||
@@ -327,23 +354,23 @@ msgstr "Drop a replacement here"
|
||||
msgid "Dump"
|
||||
msgstr "Dump"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:449
|
||||
#: src/components/DumpCreateModal.tsx:468
|
||||
msgid "Dump it"
|
||||
msgstr "Dump it"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:460
|
||||
#: src/components/DumpCreateModal.tsx:479
|
||||
msgid "Dumped!"
|
||||
msgstr "Dumped!"
|
||||
|
||||
#: src/pages/Search.tsx:174
|
||||
#: src/pages/UserDumps.tsx:80
|
||||
#: src/pages/UserPublicProfile.tsx:889
|
||||
#: src/pages/UserPublicProfile.tsx:900
|
||||
msgid "Dumps"
|
||||
msgstr "Dumps"
|
||||
|
||||
#. placeholder {0}: dumps.items.length
|
||||
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:909
|
||||
#: src/pages/UserPublicProfile.tsx:920
|
||||
msgid "Dumps ({0}{1})"
|
||||
msgstr "Dumps ({0}{1})"
|
||||
|
||||
@@ -352,12 +379,12 @@ msgid "Earlier"
|
||||
msgstr "Earlier"
|
||||
|
||||
#: src/components/CommentThread.tsx:367
|
||||
#: src/pages/Dump.tsx:468
|
||||
#: src/pages/Dump.tsx:486
|
||||
#: src/pages/PlaylistDetail.tsx:625
|
||||
msgid "Edit"
|
||||
msgstr "Edit"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:417
|
||||
#: src/components/DumpCreateModal.tsx:430
|
||||
msgid "Edit title"
|
||||
msgstr "Edit title"
|
||||
|
||||
@@ -365,7 +392,7 @@ msgstr "Edit title"
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
||||
#: src/components/CommentThread.tsx:317
|
||||
#: src/pages/Dump.tsx:421
|
||||
#: src/pages/Dump.tsx:426
|
||||
#: src/pages/PlaylistDetail.tsx:664
|
||||
msgid "edited {0}"
|
||||
msgstr "edited {0}"
|
||||
@@ -374,17 +401,17 @@ msgstr "edited {0}"
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
||||
#: src/components/CommentThread.tsx:315
|
||||
#: src/pages/Dump.tsx:419
|
||||
#: src/pages/Dump.tsx:424
|
||||
#: src/pages/PlaylistDetail.tsx:661
|
||||
msgid "Edited {0}"
|
||||
msgstr "Edited {0}"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:203
|
||||
#: src/pages/DumpEdit.tsx:204
|
||||
msgid "Editing"
|
||||
msgstr "Editing"
|
||||
|
||||
#. placeholder {0}: state.dump.title
|
||||
#: src/pages/DumpEdit.tsx:48
|
||||
#: src/pages/DumpEdit.tsx:49
|
||||
msgid "Editing {0}"
|
||||
msgstr "Editing {0}"
|
||||
|
||||
@@ -396,28 +423,36 @@ msgstr "Email address"
|
||||
msgid "Enter a query to search."
|
||||
msgstr "Enter a query to search."
|
||||
|
||||
#: src/components/CategoryManager.tsx:108
|
||||
msgid "Failed to create category"
|
||||
msgstr "Failed to create category"
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:84
|
||||
msgid "Failed to create playlist"
|
||||
msgstr "Failed to create playlist"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:86
|
||||
#: src/pages/UserPublicProfile.tsx:89
|
||||
#: src/pages/UserPublicProfile.tsx:117
|
||||
#: src/components/CategoryManager.tsx:153
|
||||
msgid "Failed to delete"
|
||||
msgstr "Failed to delete"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:87
|
||||
#: src/pages/UserPublicProfile.tsx:90
|
||||
#: src/pages/UserPublicProfile.tsx:118
|
||||
msgid "Failed to generate invite"
|
||||
msgstr "Failed to generate invite"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:82
|
||||
#: src/pages/index/FollowedFeed.tsx:83
|
||||
#: src/pages/index/HotFeed.tsx:36
|
||||
#: src/pages/index/JournalFeed.tsx:33
|
||||
#: src/pages/index/NewFeed.tsx:36
|
||||
#: src/pages/Notifications.tsx:369
|
||||
#: src/pages/UserPublicProfile.tsx:1011
|
||||
#: src/pages/UserPublicProfile.tsx:1053
|
||||
#: src/pages/UserPublicProfile.tsx:1098
|
||||
#: src/pages/UserPublicProfile.tsx:1022
|
||||
#: src/pages/UserPublicProfile.tsx:1064
|
||||
#: src/pages/UserPublicProfile.tsx:1109
|
||||
msgid "Failed to load"
|
||||
msgstr "Failed to load"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:328
|
||||
#: src/components/DumpCreateModal.tsx:341
|
||||
msgid "Failed to post"
|
||||
msgstr "Failed to post"
|
||||
|
||||
@@ -430,41 +465,45 @@ msgid "Failed to post reply"
|
||||
msgstr "Failed to post reply"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:908
|
||||
#: src/pages/UserPublicProfile.tsx:1649
|
||||
#: src/pages/UserPublicProfile.tsx:1718
|
||||
#: src/pages/UserPublicProfile.tsx:1677
|
||||
#: src/pages/UserPublicProfile.tsx:1746
|
||||
msgid "Failed to save"
|
||||
msgstr "Failed to save"
|
||||
|
||||
#: src/components/CategoryManager.tsx:197
|
||||
msgid "Failed to save category"
|
||||
msgstr "Failed to save category"
|
||||
|
||||
#: src/components/CommentThread.tsx:330
|
||||
msgid "Failed to save edit"
|
||||
msgstr "Failed to save edit"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:825
|
||||
#: src/pages/UserPublicProfile.tsx:836
|
||||
msgid "Failed to update avatar"
|
||||
msgstr "Failed to update avatar"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1587
|
||||
#: src/pages/UserPublicProfile.tsx:1615
|
||||
msgid "Failed to update role"
|
||||
msgstr "Failed to update role"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1209
|
||||
#: src/pages/UserPublicProfile.tsx:1220
|
||||
msgid "Feeds"
|
||||
msgstr "Feeds"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:360
|
||||
#: src/components/DumpCreateModal.tsx:373
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Fetching preview…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:446
|
||||
#: src/components/DumpCreateModal.tsx:465
|
||||
msgid "Fetching…"
|
||||
msgstr "Fetching…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:322
|
||||
#: src/components/DumpCreateModal.tsx:335
|
||||
#: src/components/FileDropZone.tsx:31
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:231
|
||||
#: src/components/DumpCreateModal.tsx:243
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "File too large (max 50 MB)."
|
||||
|
||||
@@ -481,17 +520,17 @@ msgstr "Follow {targetUsername}"
|
||||
msgid "Follow playlist"
|
||||
msgstr "Follow playlist"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:365
|
||||
#: src/pages/index/FollowedFeed.tsx:373
|
||||
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:351
|
||||
#: src/pages/index/FollowedFeed.tsx:359
|
||||
msgid "Follow some users to see their dumps here."
|
||||
msgstr "Follow some users to see their dumps here."
|
||||
|
||||
#: src/components/FeedTabBar.tsx:21
|
||||
#: src/pages/UserPublicProfile.tsx:891
|
||||
#: src/pages/UserPublicProfile.tsx:1243
|
||||
#: src/components/FeedTabBar.tsx:34
|
||||
#: src/pages/UserPublicProfile.tsx:902
|
||||
#: src/pages/UserPublicProfile.tsx:1254
|
||||
msgid "Followed"
|
||||
msgstr "Followed"
|
||||
|
||||
@@ -501,13 +540,13 @@ msgstr "Followed"
|
||||
msgid "Followed ({0}{1})"
|
||||
msgstr "Followed ({0}{1})"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1042
|
||||
#: src/pages/UserPublicProfile.tsx:1053
|
||||
msgid "Followed playlists"
|
||||
msgstr "Followed playlists"
|
||||
|
||||
#: src/components/FollowButton.tsx:37
|
||||
#: src/components/FollowButton.tsx:64
|
||||
#: src/pages/UserPublicProfile.tsx:1000
|
||||
#: src/pages/UserPublicProfile.tsx:1011
|
||||
msgid "Following"
|
||||
msgstr "Following"
|
||||
|
||||
@@ -515,11 +554,11 @@ msgstr "Following"
|
||||
msgid "Forgot password?"
|
||||
msgstr "Forgot password?"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:334
|
||||
#: src/pages/index/FollowedFeed.tsx:342
|
||||
msgid "From people"
|
||||
msgstr "From people"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:335
|
||||
#: src/pages/index/FollowedFeed.tsx:343
|
||||
msgid "From playlists"
|
||||
msgstr "From playlists"
|
||||
|
||||
@@ -531,8 +570,8 @@ msgstr "Go home"
|
||||
msgid "Go to login"
|
||||
msgstr "Go to login"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:17
|
||||
#: src/pages/UserPublicProfile.tsx:1222
|
||||
#: src/components/FeedTabBar.tsx:30
|
||||
#: src/pages/UserPublicProfile.tsx:1233
|
||||
msgid "Hot"
|
||||
msgstr "Hot"
|
||||
|
||||
@@ -548,21 +587,21 @@ msgstr "Invalid invite"
|
||||
msgid "Invalid link"
|
||||
msgstr "Invalid link"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:787
|
||||
#: src/pages/UserPublicProfile.tsx:798
|
||||
msgid "invited by"
|
||||
msgstr "invited by"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:892
|
||||
#: src/pages/UserPublicProfile.tsx:1087
|
||||
#: src/pages/UserPublicProfile.tsx:903
|
||||
#: src/pages/UserPublicProfile.tsx:1098
|
||||
msgid "Invitees"
|
||||
msgstr "Invitees"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:19
|
||||
#: src/pages/UserPublicProfile.tsx:1236
|
||||
#: src/components/FeedTabBar.tsx:32
|
||||
#: src/pages/UserPublicProfile.tsx:1247
|
||||
msgid "Journal"
|
||||
msgstr "Journal"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1194
|
||||
#: src/pages/UserPublicProfile.tsx:1205
|
||||
msgid "Light"
|
||||
msgstr "Light"
|
||||
|
||||
@@ -583,12 +622,12 @@ msgstr "Live updates unavailable."
|
||||
msgid "Load more"
|
||||
msgstr "Load more"
|
||||
|
||||
#: src/pages/Dump.tsx:235
|
||||
#: src/pages/DumpEdit.tsx:154
|
||||
#: src/pages/Dump.tsx:237
|
||||
#: src/pages/DumpEdit.tsx:155
|
||||
msgid "Loading dump…"
|
||||
msgstr "Loading dump…"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:110
|
||||
#: src/pages/index/FollowedFeed.tsx:111
|
||||
#: src/pages/index/HotFeed.tsx:64
|
||||
#: src/pages/index/JournalFeed.tsx:62
|
||||
#: src/pages/index/NewFeed.tsx:64
|
||||
@@ -604,15 +643,16 @@ msgstr "Loading more…"
|
||||
msgid "Loading playlist…"
|
||||
msgstr "Loading playlist…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:724
|
||||
#: src/pages/UserPublicProfile.tsx:732
|
||||
msgid "Loading profile…"
|
||||
msgstr "Loading profile…"
|
||||
|
||||
#: src/components/CategoryManager.tsx:41
|
||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||
#: src/components/TextEditor.tsx:289
|
||||
#: src/components/UserListPopover.tsx:192
|
||||
#: src/components/UserListPopover.tsx:230
|
||||
#: src/pages/index/FollowedFeed.tsx:77
|
||||
#: src/pages/index/FollowedFeed.tsx:78
|
||||
#: src/pages/index/HotFeed.tsx:32
|
||||
#: src/pages/index/JournalFeed.tsx:29
|
||||
#: src/pages/index/NewFeed.tsx:32
|
||||
@@ -620,9 +660,9 @@ msgstr "Loading profile…"
|
||||
#: src/pages/Notifications.tsx:441
|
||||
#: src/pages/UserDumps.tsx:54
|
||||
#: src/pages/UserPlaylists.tsx:345
|
||||
#: src/pages/UserPublicProfile.tsx:1005
|
||||
#: src/pages/UserPublicProfile.tsx:1047
|
||||
#: src/pages/UserPublicProfile.tsx:1092
|
||||
#: src/pages/UserPublicProfile.tsx:1016
|
||||
#: src/pages/UserPublicProfile.tsx:1058
|
||||
#: src/pages/UserPublicProfile.tsx:1103
|
||||
#: src/pages/UserUpvoted.tsx:125
|
||||
msgid "Loading…"
|
||||
msgstr "Loading…"
|
||||
@@ -642,8 +682,8 @@ msgstr "Log in to like"
|
||||
msgid "Log in to vote"
|
||||
msgstr "Log in to vote"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:745
|
||||
#: src/pages/UserPublicProfile.tsx:839
|
||||
#: src/pages/UserPublicProfile.tsx:753
|
||||
#: src/pages/UserPublicProfile.tsx:850
|
||||
msgid "Log out"
|
||||
msgstr "Log out"
|
||||
|
||||
@@ -655,7 +695,7 @@ msgstr "Logging in…"
|
||||
msgid "Login failed"
|
||||
msgstr "Login failed"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:897
|
||||
#: src/pages/UserPublicProfile.tsx:908
|
||||
msgid "Manage"
|
||||
msgstr "Manage"
|
||||
|
||||
@@ -663,24 +703,33 @@ msgstr "Manage"
|
||||
msgid "Max 50 MB"
|
||||
msgstr "Max 50 MB"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1528
|
||||
#: src/pages/UserPublicProfile.tsx:1548
|
||||
msgid "Moderator"
|
||||
msgstr "Moderator"
|
||||
|
||||
#: src/components/CategoryManager.tsx:91
|
||||
#: src/components/CategoryManager.tsx:167
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/pages/Notifications.tsx:358
|
||||
msgid "new"
|
||||
msgstr "new"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:18
|
||||
#: src/pages/UserPublicProfile.tsx:1229
|
||||
#: src/components/FeedTabBar.tsx:31
|
||||
#: src/pages/UserPublicProfile.tsx:1240
|
||||
msgid "New"
|
||||
msgstr "New"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:292
|
||||
#: src/components/CategoryManager.tsx:90
|
||||
msgid "New category name"
|
||||
msgstr "New category name"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:305
|
||||
#: src/components/DumpFab.tsx:65
|
||||
#: src/components/DumpFab.tsx:66
|
||||
#: src/pages/UserDumps.tsx:88
|
||||
#: src/pages/UserPublicProfile.tsx:1307
|
||||
#: src/pages/UserPublicProfile.tsx:1327
|
||||
msgid "New dump"
|
||||
msgstr "New dump"
|
||||
|
||||
@@ -694,6 +743,10 @@ msgstr "New password"
|
||||
msgid "New playlist"
|
||||
msgstr "New playlist"
|
||||
|
||||
#: src/components/CategoryManager.tsx:46
|
||||
msgid "No categories yet."
|
||||
msgstr "No categories yet."
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:680
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "No dumps in this playlist yet."
|
||||
@@ -713,11 +766,11 @@ msgid "No emoji found."
|
||||
msgstr "No emoji found."
|
||||
|
||||
#: src/pages/UserPlaylists.tsx:442
|
||||
#: src/pages/UserPublicProfile.tsx:1060
|
||||
#: src/pages/UserPublicProfile.tsx:1071
|
||||
msgid "No followed playlists yet."
|
||||
msgstr "No followed playlists yet."
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1105
|
||||
#: src/pages/UserPublicProfile.tsx:1116
|
||||
msgid "No invitees yet."
|
||||
msgstr "No invitees yet."
|
||||
|
||||
@@ -731,7 +784,7 @@ msgstr "No playlists match \"{q}\"."
|
||||
|
||||
#: src/components/PlaylistMembershipPanel.tsx:34
|
||||
#: src/pages/UserPlaylists.tsx:400
|
||||
#: src/pages/UserPublicProfile.tsx:971
|
||||
#: src/pages/UserPublicProfile.tsx:982
|
||||
msgid "No playlists yet."
|
||||
msgstr "No playlists yet."
|
||||
|
||||
@@ -739,14 +792,14 @@ msgstr "No playlists yet."
|
||||
msgid "No users match \"{q}\"."
|
||||
msgstr "No users match \"{q}\"."
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1018
|
||||
#: src/pages/UserPublicProfile.tsx:1029
|
||||
msgid "Not following anyone yet."
|
||||
msgstr "Not following anyone yet."
|
||||
|
||||
#: src/pages/Notifications.tsx:376
|
||||
#: src/pages/UserDumps.tsx:99
|
||||
#: src/pages/UserPublicProfile.tsx:1318
|
||||
#: src/pages/UserPublicProfile.tsx:1440
|
||||
#: src/pages/UserPublicProfile.tsx:1338
|
||||
#: src/pages/UserPublicProfile.tsx:1460
|
||||
#: src/pages/UserUpvoted.tsx:159
|
||||
msgid "Nothing here yet."
|
||||
msgstr "Nothing here yet."
|
||||
@@ -774,7 +827,7 @@ msgid "Page not found"
|
||||
msgstr "Page not found"
|
||||
|
||||
#: src/pages/UserLogin.tsx:74
|
||||
#: src/pages/UserPublicProfile.tsx:1125
|
||||
#: src/pages/UserPublicProfile.tsx:1136
|
||||
msgid "Password"
|
||||
msgstr "Password"
|
||||
|
||||
@@ -804,17 +857,17 @@ msgstr "Playlist title"
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:177
|
||||
#: src/pages/UserPlaylists.tsx:371
|
||||
#: src/pages/UserPublicProfile.tsx:890
|
||||
#: src/pages/UserPublicProfile.tsx:901
|
||||
msgid "Playlists"
|
||||
msgstr "Playlists"
|
||||
|
||||
#. placeholder {0}: playlists.items.length
|
||||
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:940
|
||||
#: src/pages/UserPublicProfile.tsx:951
|
||||
msgid "Playlists ({0}{1})"
|
||||
msgstr "Playlists ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:228
|
||||
#: src/components/DumpCreateModal.tsx:240
|
||||
msgid "Please select a file."
|
||||
msgstr "Please select a file."
|
||||
|
||||
@@ -831,11 +884,11 @@ msgstr "Post reply"
|
||||
msgid "Posting…"
|
||||
msgstr "Posting…"
|
||||
|
||||
#: src/components/DumpCard.tsx:114
|
||||
#: src/components/JournalCard.tsx:119
|
||||
#: src/components/DumpCard.tsx:119
|
||||
#: src/components/JournalCard.tsx:121
|
||||
#: src/components/PlaylistCard.tsx:73
|
||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||
#: src/pages/Dump.tsx:427
|
||||
#: src/pages/Dump.tsx:432
|
||||
#: src/pages/PlaylistDetail.tsx:644
|
||||
msgid "private"
|
||||
msgstr "private"
|
||||
@@ -855,11 +908,11 @@ msgstr "public"
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:232
|
||||
#: src/pages/DumpEdit.tsx:233
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Refresh metadata"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:231
|
||||
#: src/pages/DumpEdit.tsx:232
|
||||
msgid "Refreshing…"
|
||||
msgstr "Refreshing…"
|
||||
|
||||
@@ -877,7 +930,7 @@ msgstr "Registering…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Registration failed"
|
||||
|
||||
#: src/pages/Dump.tsx:480
|
||||
#: src/pages/Dump.tsx:498
|
||||
msgid "Related"
|
||||
msgstr "Related"
|
||||
|
||||
@@ -897,7 +950,7 @@ msgstr "Remove like"
|
||||
msgid "Remove vote"
|
||||
msgstr "Remove vote"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:413
|
||||
#: src/pages/DumpEdit.tsx:428
|
||||
msgid "Replace file"
|
||||
msgstr "Replace file"
|
||||
|
||||
@@ -917,36 +970,38 @@ msgstr "Reset failed"
|
||||
msgid "Reset password"
|
||||
msgstr "Reset password"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:364
|
||||
#: src/pages/DumpEdit.tsx:382
|
||||
#: src/pages/DumpEdit.tsx:379
|
||||
#: src/pages/DumpEdit.tsx:397
|
||||
msgid "Reset to default"
|
||||
msgstr "Reset to default"
|
||||
|
||||
#: src/pages/Dump.tsx:252
|
||||
#: src/pages/DumpEdit.tsx:171
|
||||
#: src/pages/Dump.tsx:254
|
||||
#: src/pages/DumpEdit.tsx:172
|
||||
msgid "Retry"
|
||||
msgstr "Retry"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1571
|
||||
#: src/pages/UserPublicProfile.tsx:1599
|
||||
msgid "Role"
|
||||
msgstr "Role"
|
||||
|
||||
#: src/components/CategoryManager.tsx:184
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:360
|
||||
#: src/pages/DumpEdit.tsx:446
|
||||
#: src/pages/Dump.tsx:365
|
||||
#: src/pages/DumpEdit.tsx:466
|
||||
#: src/pages/PlaylistDetail.tsx:927
|
||||
#: src/pages/UserPublicProfile.tsx:1638
|
||||
#: src/pages/UserPublicProfile.tsx:1708
|
||||
#: src/pages/UserPublicProfile.tsx:1666
|
||||
#: src/pages/UserPublicProfile.tsx:1736
|
||||
msgid "Save"
|
||||
msgstr "Save"
|
||||
|
||||
#: src/components/CategoryManager.tsx:181
|
||||
#: src/components/ChangePasswordModal.tsx:100
|
||||
#: src/components/CommentThread.tsx:329
|
||||
#: src/pages/Dump.tsx:359
|
||||
#: src/pages/Dump.tsx:364
|
||||
#: src/pages/PlaylistDetail.tsx:923
|
||||
#: src/pages/ResetPassword.tsx:126
|
||||
#: src/pages/UserPublicProfile.tsx:1635
|
||||
#: src/pages/UserPublicProfile.tsx:1705
|
||||
#: src/pages/UserPublicProfile.tsx:1663
|
||||
#: src/pages/UserPublicProfile.tsx:1733
|
||||
msgid "Saving…"
|
||||
msgstr "Saving…"
|
||||
|
||||
@@ -985,7 +1040,7 @@ msgstr "Server unreachable"
|
||||
msgid "Set new password"
|
||||
msgstr "Set new password"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:894
|
||||
#: src/pages/UserPublicProfile.tsx:905
|
||||
msgid "Settings"
|
||||
msgstr "Settings"
|
||||
|
||||
@@ -994,7 +1049,7 @@ msgstr "Settings"
|
||||
msgid "Something went wrong"
|
||||
msgstr "Something went wrong"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1144
|
||||
#: src/pages/UserPublicProfile.tsx:1155
|
||||
msgid "Style"
|
||||
msgstr "Style"
|
||||
|
||||
@@ -1018,17 +1073,17 @@ msgstr "This page does not exist."
|
||||
msgid "This reset link is missing or malformed."
|
||||
msgstr "This reset link is missing or malformed."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:348
|
||||
#: src/pages/DumpEdit.tsx:363
|
||||
msgid "Thumbnail"
|
||||
msgstr "Thumbnail"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:386
|
||||
#: src/components/DumpCreateModal.tsx:399
|
||||
#: src/components/PlaylistCreateForm.tsx:70
|
||||
#: src/pages/DumpEdit.tsx:372
|
||||
#: src/pages/DumpEdit.tsx:387
|
||||
msgid "Title"
|
||||
msgstr "Title"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:229
|
||||
#: src/components/DumpCreateModal.tsx:241
|
||||
msgid "Title is required."
|
||||
msgstr "Title is required."
|
||||
|
||||
@@ -1048,11 +1103,11 @@ msgstr "Unfollow {targetUsername}"
|
||||
msgid "Unfollow playlist"
|
||||
msgstr "Unfollow playlist"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:689
|
||||
#: src/pages/UserPublicProfile.tsx:697
|
||||
msgid "Upload failed"
|
||||
msgstr "Upload failed"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:447
|
||||
#: src/components/DumpCreateModal.tsx:466
|
||||
msgid "Uploading…"
|
||||
msgstr "Uploading…"
|
||||
|
||||
@@ -1066,24 +1121,33 @@ msgstr "Upvoted"
|
||||
|
||||
#. placeholder {0}: votes.items.length
|
||||
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:931
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Upvoted ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:335
|
||||
#: src/pages/DumpEdit.tsx:395
|
||||
#: src/components/DumpCreateModal.tsx:348
|
||||
#: src/pages/DumpEdit.tsx:410
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:216
|
||||
#: src/components/DumpCreateModal.tsx:227
|
||||
msgid "URL is required."
|
||||
msgstr "URL is required."
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1527
|
||||
#: src/components/CategoryManager.tsx:98
|
||||
#: src/components/CategoryManager.tsx:174
|
||||
msgid "URL slug"
|
||||
msgstr "URL slug"
|
||||
|
||||
#: src/components/CategoryManager.tsx:97
|
||||
msgid "url-slug"
|
||||
msgstr "url-slug"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1547
|
||||
msgid "User"
|
||||
msgstr "User"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1255
|
||||
#: src/pages/UserPublicProfile.tsx:1266
|
||||
msgid "User management"
|
||||
msgstr "User management"
|
||||
|
||||
@@ -1100,30 +1164,30 @@ msgstr "Username"
|
||||
msgid "Users"
|
||||
msgstr "Users"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:990
|
||||
#: src/pages/UserPublicProfile.tsx:1033
|
||||
#: src/pages/UserPublicProfile.tsx:1075
|
||||
#: src/pages/UserPublicProfile.tsx:1339
|
||||
#: src/pages/UserPublicProfile.tsx:1470
|
||||
#: src/pages/UserPublicProfile.tsx:1001
|
||||
#: src/pages/UserPublicProfile.tsx:1044
|
||||
#: src/pages/UserPublicProfile.tsx:1086
|
||||
#: src/pages/UserPublicProfile.tsx:1359
|
||||
#: src/pages/UserPublicProfile.tsx:1490
|
||||
msgid "View all →"
|
||||
msgstr "View all →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:462
|
||||
#: src/components/DumpCreateModal.tsx:481
|
||||
msgid "View dump →"
|
||||
msgstr "View dump →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:431
|
||||
#: src/pages/DumpEdit.tsx:425
|
||||
#: src/components/DumpCreateModal.tsx:444
|
||||
#: src/pages/DumpEdit.tsx:440
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "What makes it worth it?"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:874
|
||||
#: src/pages/UserPublicProfile.tsx:1697
|
||||
#: src/pages/UserPublicProfile.tsx:885
|
||||
#: src/pages/UserPublicProfile.tsx:1725
|
||||
msgid "Who am I?"
|
||||
msgstr "Who am I?"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:430
|
||||
#: src/pages/DumpEdit.tsx:424
|
||||
#: src/components/DumpCreateModal.tsx:443
|
||||
#: src/pages/DumpEdit.tsx:439
|
||||
msgid "Why?"
|
||||
msgstr "Why?"
|
||||
|
||||
@@ -1139,7 +1203,7 @@ msgstr "Yesterday"
|
||||
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/FollowedFeed.tsx:115
|
||||
#: src/pages/index/FollowedFeed.tsx:116
|
||||
#: src/pages/index/HotFeed.tsx:69
|
||||
#: src/pages/index/JournalFeed.tsx:67
|
||||
#: src/pages/index/NewFeed.tsx:69
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,8 +18,8 @@ msgid "[deleted]"
|
||||
msgstr "[supprimé]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: src/components/DumpCard.tsx:105
|
||||
#: src/components/JournalCard.tsx:110
|
||||
#: src/components/DumpCard.tsx:110
|
||||
#: src/components/JournalCard.tsx:112
|
||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||
|
||||
@@ -43,13 +43,13 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:570
|
||||
#: src/pages/UserPublicProfile.tsx:741
|
||||
#: src/pages/UserPublicProfile.tsx:749
|
||||
msgid "← Back"
|
||||
msgstr "← Retour"
|
||||
|
||||
#: src/pages/Dump.tsx:259
|
||||
#: src/pages/Dump.tsx:472
|
||||
#: src/pages/DumpEdit.tsx:178
|
||||
#: src/pages/Dump.tsx:261
|
||||
#: src/pages/Dump.tsx:490
|
||||
#: src/pages/DumpEdit.tsx:179
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Retour à toutes les recos"
|
||||
|
||||
@@ -59,7 +59,7 @@ msgstr "← Retour à toutes les recos"
|
||||
msgid "← Back to profile"
|
||||
msgstr "← Retour au profil"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:114
|
||||
#: src/pages/UserPublicProfile.tsx:115
|
||||
msgid "+ Invite someone"
|
||||
msgstr "+ Inviter quelqu'un"
|
||||
|
||||
@@ -67,7 +67,7 @@ msgstr "+ Inviter quelqu'un"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ Nouvelle collection"
|
||||
|
||||
#: src/pages/Dump.tsx:327
|
||||
#: src/pages/Dump.tsx:332
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Collection"
|
||||
|
||||
@@ -125,7 +125,7 @@ msgstr "un commentaire"
|
||||
msgid "a post"
|
||||
msgstr "une publication"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1120
|
||||
#: src/pages/UserPublicProfile.tsx:1131
|
||||
msgid "Account"
|
||||
msgstr "Compte"
|
||||
|
||||
@@ -133,24 +133,29 @@ msgstr "Compte"
|
||||
msgid "Add a comment…"
|
||||
msgstr "Ajouter un commentaire…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:816
|
||||
#: src/pages/UserPublicProfile.tsx:827
|
||||
msgid "Add email…"
|
||||
msgstr "Ajouter un e-mail…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:292
|
||||
#: src/components/DumpCreateModal.tsx:305
|
||||
msgid "Add to playlist"
|
||||
msgstr "Ajouter à la collection"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1529
|
||||
#: src/pages/UserPublicProfile.tsx:1549
|
||||
msgid "Admin"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: src/components/CategorySwitcher.tsx:79
|
||||
#: src/components/CategorySwitcher.tsx:82
|
||||
msgid "All"
|
||||
msgstr "Tout"
|
||||
|
||||
#: src/pages/UserRegister.tsx:156
|
||||
msgid "Already have an account? <0>Log in</0>"
|
||||
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1139
|
||||
#: src/pages/UserPublicProfile.tsx:1150
|
||||
msgid "Appearance"
|
||||
msgstr "Apparence"
|
||||
|
||||
@@ -161,7 +166,7 @@ msgstr "Apparence"
|
||||
msgid "At least {0} characters"
|
||||
msgstr "Au moins {0} caractères"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1187
|
||||
#: src/pages/UserPublicProfile.tsx:1198
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
@@ -178,11 +183,11 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
|
||||
#: src/components/CommentThread.tsx:124
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:368
|
||||
#: src/pages/DumpEdit.tsx:443
|
||||
#: src/pages/Dump.tsx:373
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/PlaylistDetail.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:1646
|
||||
#: src/pages/UserPublicProfile.tsx:1716
|
||||
#: src/pages/UserPublicProfile.tsx:1674
|
||||
#: src/pages/UserPublicProfile.tsx:1744
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
|
||||
@@ -194,7 +199,16 @@ msgstr "Annuler la suppression"
|
||||
msgid "Cancel search"
|
||||
msgstr "Annuler la recherche"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:768
|
||||
#: src/components/CategorySelect.tsx:22
|
||||
#: src/pages/UserPublicProfile.tsx:1281
|
||||
msgid "Categories"
|
||||
msgstr "Catégories"
|
||||
|
||||
#: src/components/CategorySwitcher.tsx:102
|
||||
msgid "Category"
|
||||
msgstr "Catégorie"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:776
|
||||
msgid "Change avatar"
|
||||
msgstr "Changer l'avatar"
|
||||
|
||||
@@ -203,7 +217,7 @@ msgstr "Changer l'avatar"
|
||||
msgid "Change password"
|
||||
msgstr "Changer le mot de passe"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1132
|
||||
#: src/pages/UserPublicProfile.tsx:1143
|
||||
msgid "Change password…"
|
||||
msgstr "Changer le mot de passe…"
|
||||
|
||||
@@ -216,7 +230,7 @@ msgstr "Vérification de l'invitation…"
|
||||
msgid "Close"
|
||||
msgstr "Fermer"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1179
|
||||
#: src/pages/UserPublicProfile.tsx:1190
|
||||
msgid "Color scheme"
|
||||
msgstr "Thème de couleur"
|
||||
|
||||
@@ -225,11 +239,11 @@ msgstr "Thème de couleur"
|
||||
msgid "Confirm new password"
|
||||
msgstr "Confirmer le nouveau mot de passe"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:105
|
||||
#: src/pages/UserPublicProfile.tsx:106
|
||||
msgid "Copied!"
|
||||
msgstr "Copié !"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:105
|
||||
#: src/pages/UserPublicProfile.tsx:106
|
||||
msgid "Copy"
|
||||
msgstr "Copier"
|
||||
|
||||
@@ -241,10 +255,11 @@ msgstr "Impossible de changer le mot de passe"
|
||||
msgid "Could not load."
|
||||
msgstr "Impossible de charger."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:344
|
||||
#: src/pages/DumpEdit.tsx:359
|
||||
msgid "Could not save"
|
||||
msgstr "Sauvegarde impossible"
|
||||
|
||||
#: src/components/CategoryManager.tsx:106
|
||||
#: src/components/PlaylistCreateForm.tsx:87
|
||||
msgid "Create"
|
||||
msgstr "Créer"
|
||||
@@ -259,6 +274,7 @@ msgstr "Créer et ajouter"
|
||||
msgid "Created ({0}{1})"
|
||||
msgstr "Créées ({0}{1})"
|
||||
|
||||
#: src/components/CategoryManager.tsx:103
|
||||
#: src/components/PlaylistCreateForm.tsx:86
|
||||
msgid "Creating…"
|
||||
msgstr "Création…"
|
||||
@@ -267,14 +283,16 @@ msgstr "Création…"
|
||||
msgid "Current password"
|
||||
msgstr "Mot de passe actuel"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1201
|
||||
#: src/pages/UserPublicProfile.tsx:1212
|
||||
msgid "Dark"
|
||||
msgstr "Sombre"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1214
|
||||
#: src/pages/UserPublicProfile.tsx:1225
|
||||
msgid "Default tab"
|
||||
msgstr "Onglet par défaut"
|
||||
|
||||
#: src/components/CategoryManager.tsx:191
|
||||
#: src/components/CategoryManager.tsx:192
|
||||
#: src/components/CommentThread.tsx:376
|
||||
#: src/components/CommentThread.tsx:382
|
||||
#: src/components/ConfirmModal.tsx:16
|
||||
@@ -282,8 +300,17 @@ msgstr "Onglet par défaut"
|
||||
msgid "Delete"
|
||||
msgstr "Supprimer"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:252
|
||||
#: src/pages/DumpEdit.tsx:439
|
||||
#: src/components/CategoryManager.tsx:202
|
||||
msgid "Delete category"
|
||||
msgstr "Supprimer la catégorie"
|
||||
|
||||
#. placeholder {0}: category.name
|
||||
#: src/components/CategoryManager.tsx:201
|
||||
msgid "Delete category \"{0}\"? This cannot be undone."
|
||||
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:253
|
||||
#: src/pages/DumpEdit.tsx:459
|
||||
msgid "Delete dump"
|
||||
msgstr "Supprimer la reco"
|
||||
|
||||
@@ -297,7 +324,7 @@ msgstr "Supprimer la collection"
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Supprimer ce commentaire ?"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:251
|
||||
#: src/pages/DumpEdit.tsx:252
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||
|
||||
@@ -311,7 +338,7 @@ msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (facultatif)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:479
|
||||
#: src/components/DumpCreateModal.tsx:498
|
||||
msgid "Done"
|
||||
msgstr "Terminé"
|
||||
|
||||
@@ -319,7 +346,7 @@ msgstr "Terminé"
|
||||
msgid "Drop a file here"
|
||||
msgstr "Déposez un fichier ici"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:414
|
||||
#: src/pages/DumpEdit.tsx:429
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Déposez un fichier de remplacement ici"
|
||||
|
||||
@@ -327,23 +354,23 @@ msgstr "Déposez un fichier de remplacement ici"
|
||||
msgid "Dump"
|
||||
msgstr "Reco"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:449
|
||||
#: src/components/DumpCreateModal.tsx:468
|
||||
msgid "Dump it"
|
||||
msgstr "Recommander"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:460
|
||||
#: src/components/DumpCreateModal.tsx:479
|
||||
msgid "Dumped!"
|
||||
msgstr "Recommandé !"
|
||||
|
||||
#: src/pages/Search.tsx:174
|
||||
#: src/pages/UserDumps.tsx:80
|
||||
#: src/pages/UserPublicProfile.tsx:889
|
||||
#: src/pages/UserPublicProfile.tsx:900
|
||||
msgid "Dumps"
|
||||
msgstr "Recos"
|
||||
|
||||
#. placeholder {0}: dumps.items.length
|
||||
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:909
|
||||
#: src/pages/UserPublicProfile.tsx:920
|
||||
msgid "Dumps ({0}{1})"
|
||||
msgstr "Recos ({0}{1})"
|
||||
|
||||
@@ -352,12 +379,12 @@ msgid "Earlier"
|
||||
msgstr "Plus tôt"
|
||||
|
||||
#: src/components/CommentThread.tsx:367
|
||||
#: src/pages/Dump.tsx:468
|
||||
#: src/pages/Dump.tsx:486
|
||||
#: src/pages/PlaylistDetail.tsx:625
|
||||
msgid "Edit"
|
||||
msgstr "Modifier"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:417
|
||||
#: src/components/DumpCreateModal.tsx:430
|
||||
msgid "Edit title"
|
||||
msgstr "Modifier le titre"
|
||||
|
||||
@@ -365,7 +392,7 @@ msgstr "Modifier le titre"
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
||||
#: src/components/CommentThread.tsx:317
|
||||
#: src/pages/Dump.tsx:421
|
||||
#: src/pages/Dump.tsx:426
|
||||
#: src/pages/PlaylistDetail.tsx:664
|
||||
msgid "edited {0}"
|
||||
msgstr "modifié {0}"
|
||||
@@ -374,17 +401,17 @@ msgstr "modifié {0}"
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
||||
#: src/components/CommentThread.tsx:315
|
||||
#: src/pages/Dump.tsx:419
|
||||
#: src/pages/Dump.tsx:424
|
||||
#: src/pages/PlaylistDetail.tsx:661
|
||||
msgid "Edited {0}"
|
||||
msgstr "Modifié le {0}"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:203
|
||||
#: src/pages/DumpEdit.tsx:204
|
||||
msgid "Editing"
|
||||
msgstr "Modification"
|
||||
|
||||
#. placeholder {0}: state.dump.title
|
||||
#: src/pages/DumpEdit.tsx:48
|
||||
#: src/pages/DumpEdit.tsx:49
|
||||
msgid "Editing {0}"
|
||||
msgstr "Édition de {0}"
|
||||
|
||||
@@ -396,28 +423,36 @@ msgstr "Adresse e-mail"
|
||||
msgid "Enter a query to search."
|
||||
msgstr "Saisissez une recherche."
|
||||
|
||||
#: src/components/CategoryManager.tsx:108
|
||||
msgid "Failed to create category"
|
||||
msgstr "Échec de la création de la catégorie"
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:84
|
||||
msgid "Failed to create playlist"
|
||||
msgstr "Impossible de créer la collection"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:86
|
||||
#: src/pages/UserPublicProfile.tsx:89
|
||||
#: src/pages/UserPublicProfile.tsx:117
|
||||
#: src/components/CategoryManager.tsx:153
|
||||
msgid "Failed to delete"
|
||||
msgstr "Suppression échouée"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:87
|
||||
#: src/pages/UserPublicProfile.tsx:90
|
||||
#: src/pages/UserPublicProfile.tsx:118
|
||||
msgid "Failed to generate invite"
|
||||
msgstr "Impossible de générer une invitation"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:82
|
||||
#: src/pages/index/FollowedFeed.tsx:83
|
||||
#: src/pages/index/HotFeed.tsx:36
|
||||
#: src/pages/index/JournalFeed.tsx:33
|
||||
#: src/pages/index/NewFeed.tsx:36
|
||||
#: src/pages/Notifications.tsx:369
|
||||
#: src/pages/UserPublicProfile.tsx:1011
|
||||
#: src/pages/UserPublicProfile.tsx:1053
|
||||
#: src/pages/UserPublicProfile.tsx:1098
|
||||
#: src/pages/UserPublicProfile.tsx:1022
|
||||
#: src/pages/UserPublicProfile.tsx:1064
|
||||
#: src/pages/UserPublicProfile.tsx:1109
|
||||
msgid "Failed to load"
|
||||
msgstr "Chargement échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:328
|
||||
#: src/components/DumpCreateModal.tsx:341
|
||||
msgid "Failed to post"
|
||||
msgstr "Publication échouée"
|
||||
|
||||
@@ -430,41 +465,45 @@ msgid "Failed to post reply"
|
||||
msgstr "Impossible de publier la réponse"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:908
|
||||
#: src/pages/UserPublicProfile.tsx:1649
|
||||
#: src/pages/UserPublicProfile.tsx:1718
|
||||
#: src/pages/UserPublicProfile.tsx:1677
|
||||
#: src/pages/UserPublicProfile.tsx:1746
|
||||
msgid "Failed to save"
|
||||
msgstr "Enregistrement échoué"
|
||||
|
||||
#: src/components/CategoryManager.tsx:197
|
||||
msgid "Failed to save category"
|
||||
msgstr "Échec de l’enregistrement de la catégorie"
|
||||
|
||||
#: src/components/CommentThread.tsx:330
|
||||
msgid "Failed to save edit"
|
||||
msgstr "Impossible d'enregistrer la modification"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:825
|
||||
#: src/pages/UserPublicProfile.tsx:836
|
||||
msgid "Failed to update avatar"
|
||||
msgstr "Impossible de mettre à jour l'avatar"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1587
|
||||
#: src/pages/UserPublicProfile.tsx:1615
|
||||
msgid "Failed to update role"
|
||||
msgstr "Erreur lors de la mise à jour du rôle"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1209
|
||||
#: src/pages/UserPublicProfile.tsx:1220
|
||||
msgid "Feeds"
|
||||
msgstr "Flux"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:360
|
||||
#: src/components/DumpCreateModal.tsx:373
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Récupération de l'aperçu…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:446
|
||||
#: src/components/DumpCreateModal.tsx:465
|
||||
msgid "Fetching…"
|
||||
msgstr "Récupération…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:322
|
||||
#: src/components/DumpCreateModal.tsx:335
|
||||
#: src/components/FileDropZone.tsx:31
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:231
|
||||
#: src/components/DumpCreateModal.tsx:243
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||
|
||||
@@ -481,17 +520,17 @@ msgstr "Suivre {targetUsername}"
|
||||
msgid "Follow playlist"
|
||||
msgstr "Suivre la collection"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:365
|
||||
#: src/pages/index/FollowedFeed.tsx:373
|
||||
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:351
|
||||
#: src/pages/index/FollowedFeed.tsx:359
|
||||
msgid "Follow some users to see their dumps here."
|
||||
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||
|
||||
#: src/components/FeedTabBar.tsx:21
|
||||
#: src/pages/UserPublicProfile.tsx:891
|
||||
#: src/pages/UserPublicProfile.tsx:1243
|
||||
#: src/components/FeedTabBar.tsx:34
|
||||
#: src/pages/UserPublicProfile.tsx:902
|
||||
#: src/pages/UserPublicProfile.tsx:1254
|
||||
msgid "Followed"
|
||||
msgstr "Suivi"
|
||||
|
||||
@@ -501,13 +540,13 @@ msgstr "Suivi"
|
||||
msgid "Followed ({0}{1})"
|
||||
msgstr "Suivies ({0}{1})"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1042
|
||||
#: src/pages/UserPublicProfile.tsx:1053
|
||||
msgid "Followed playlists"
|
||||
msgstr "Collections suivies"
|
||||
|
||||
#: src/components/FollowButton.tsx:37
|
||||
#: src/components/FollowButton.tsx:64
|
||||
#: src/pages/UserPublicProfile.tsx:1000
|
||||
#: src/pages/UserPublicProfile.tsx:1011
|
||||
msgid "Following"
|
||||
msgstr "Abonné"
|
||||
|
||||
@@ -515,11 +554,11 @@ msgstr "Abonné"
|
||||
msgid "Forgot password?"
|
||||
msgstr "Mot de passe oublié ?"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:334
|
||||
#: src/pages/index/FollowedFeed.tsx:342
|
||||
msgid "From people"
|
||||
msgstr "De personnes"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:335
|
||||
#: src/pages/index/FollowedFeed.tsx:343
|
||||
msgid "From playlists"
|
||||
msgstr "De collections"
|
||||
|
||||
@@ -531,8 +570,8 @@ msgstr "Accueil"
|
||||
msgid "Go to login"
|
||||
msgstr "Aller à la connexion"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:17
|
||||
#: src/pages/UserPublicProfile.tsx:1222
|
||||
#: src/components/FeedTabBar.tsx:30
|
||||
#: src/pages/UserPublicProfile.tsx:1233
|
||||
msgid "Hot"
|
||||
msgstr "Tendances"
|
||||
|
||||
@@ -548,21 +587,21 @@ msgstr "Invitation invalide"
|
||||
msgid "Invalid link"
|
||||
msgstr "Lien invalide"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:787
|
||||
#: src/pages/UserPublicProfile.tsx:798
|
||||
msgid "invited by"
|
||||
msgstr "invité par"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:892
|
||||
#: src/pages/UserPublicProfile.tsx:1087
|
||||
#: src/pages/UserPublicProfile.tsx:903
|
||||
#: src/pages/UserPublicProfile.tsx:1098
|
||||
msgid "Invitees"
|
||||
msgstr "Invités"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:19
|
||||
#: src/pages/UserPublicProfile.tsx:1236
|
||||
#: src/components/FeedTabBar.tsx:32
|
||||
#: src/pages/UserPublicProfile.tsx:1247
|
||||
msgid "Journal"
|
||||
msgstr "Journal"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1194
|
||||
#: src/pages/UserPublicProfile.tsx:1205
|
||||
msgid "Light"
|
||||
msgstr "Clair"
|
||||
|
||||
@@ -583,12 +622,12 @@ msgstr "Mises à jour en direct indisponibles."
|
||||
msgid "Load more"
|
||||
msgstr "Charger plus"
|
||||
|
||||
#: src/pages/Dump.tsx:235
|
||||
#: src/pages/DumpEdit.tsx:154
|
||||
#: src/pages/Dump.tsx:237
|
||||
#: src/pages/DumpEdit.tsx:155
|
||||
msgid "Loading dump…"
|
||||
msgstr "Chargement de la reco…"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:110
|
||||
#: src/pages/index/FollowedFeed.tsx:111
|
||||
#: src/pages/index/HotFeed.tsx:64
|
||||
#: src/pages/index/JournalFeed.tsx:62
|
||||
#: src/pages/index/NewFeed.tsx:64
|
||||
@@ -604,15 +643,16 @@ msgstr "Chargement…"
|
||||
msgid "Loading playlist…"
|
||||
msgstr "Chargement de la collection…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:724
|
||||
#: src/pages/UserPublicProfile.tsx:732
|
||||
msgid "Loading profile…"
|
||||
msgstr "Chargement du profil…"
|
||||
|
||||
#: src/components/CategoryManager.tsx:41
|
||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||
#: src/components/TextEditor.tsx:289
|
||||
#: src/components/UserListPopover.tsx:192
|
||||
#: src/components/UserListPopover.tsx:230
|
||||
#: src/pages/index/FollowedFeed.tsx:77
|
||||
#: src/pages/index/FollowedFeed.tsx:78
|
||||
#: src/pages/index/HotFeed.tsx:32
|
||||
#: src/pages/index/JournalFeed.tsx:29
|
||||
#: src/pages/index/NewFeed.tsx:32
|
||||
@@ -620,9 +660,9 @@ msgstr "Chargement du profil…"
|
||||
#: src/pages/Notifications.tsx:441
|
||||
#: src/pages/UserDumps.tsx:54
|
||||
#: src/pages/UserPlaylists.tsx:345
|
||||
#: src/pages/UserPublicProfile.tsx:1005
|
||||
#: src/pages/UserPublicProfile.tsx:1047
|
||||
#: src/pages/UserPublicProfile.tsx:1092
|
||||
#: src/pages/UserPublicProfile.tsx:1016
|
||||
#: src/pages/UserPublicProfile.tsx:1058
|
||||
#: src/pages/UserPublicProfile.tsx:1103
|
||||
#: src/pages/UserUpvoted.tsx:125
|
||||
msgid "Loading…"
|
||||
msgstr "Chargement…"
|
||||
@@ -642,8 +682,8 @@ msgstr "Se connecter pour aimer"
|
||||
msgid "Log in to vote"
|
||||
msgstr "Se connecter pour voter"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:745
|
||||
#: src/pages/UserPublicProfile.tsx:839
|
||||
#: src/pages/UserPublicProfile.tsx:753
|
||||
#: src/pages/UserPublicProfile.tsx:850
|
||||
msgid "Log out"
|
||||
msgstr "Se déconnecter"
|
||||
|
||||
@@ -655,7 +695,7 @@ msgstr "Connexion…"
|
||||
msgid "Login failed"
|
||||
msgstr "Connexion échouée"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:897
|
||||
#: src/pages/UserPublicProfile.tsx:908
|
||||
msgid "Manage"
|
||||
msgstr "Administration"
|
||||
|
||||
@@ -663,24 +703,33 @@ msgstr "Administration"
|
||||
msgid "Max 50 MB"
|
||||
msgstr "Max 50 Mo"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1528
|
||||
#: src/pages/UserPublicProfile.tsx:1548
|
||||
msgid "Moderator"
|
||||
msgstr "Modérateur"
|
||||
|
||||
#: src/components/CategoryManager.tsx:91
|
||||
#: src/components/CategoryManager.tsx:167
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: src/pages/Notifications.tsx:358
|
||||
msgid "new"
|
||||
msgstr "nouveau"
|
||||
|
||||
#: src/components/FeedTabBar.tsx:18
|
||||
#: src/pages/UserPublicProfile.tsx:1229
|
||||
#: src/components/FeedTabBar.tsx:31
|
||||
#: src/pages/UserPublicProfile.tsx:1240
|
||||
msgid "New"
|
||||
msgstr "Nouveau"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:292
|
||||
#: src/components/CategoryManager.tsx:90
|
||||
msgid "New category name"
|
||||
msgstr "Nom de la nouvelle catégorie"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:305
|
||||
#: src/components/DumpFab.tsx:65
|
||||
#: src/components/DumpFab.tsx:66
|
||||
#: src/pages/UserDumps.tsx:88
|
||||
#: src/pages/UserPublicProfile.tsx:1307
|
||||
#: src/pages/UserPublicProfile.tsx:1327
|
||||
msgid "New dump"
|
||||
msgstr "Nouvelle reco"
|
||||
|
||||
@@ -694,6 +743,10 @@ msgstr "Nouveau mot de passe"
|
||||
msgid "New playlist"
|
||||
msgstr "Nouvelle collection"
|
||||
|
||||
#: src/components/CategoryManager.tsx:46
|
||||
msgid "No categories yet."
|
||||
msgstr "Aucune catégorie pour le moment."
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:680
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "Aucune reco dans cette collection pour l'instant."
|
||||
@@ -713,11 +766,11 @@ msgid "No emoji found."
|
||||
msgstr "Aucun emoji trouvé."
|
||||
|
||||
#: src/pages/UserPlaylists.tsx:442
|
||||
#: src/pages/UserPublicProfile.tsx:1060
|
||||
#: src/pages/UserPublicProfile.tsx:1071
|
||||
msgid "No followed playlists yet."
|
||||
msgstr "Pas encore de collections suivies."
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1105
|
||||
#: src/pages/UserPublicProfile.tsx:1116
|
||||
msgid "No invitees yet."
|
||||
msgstr "Aucun invité pour le moment."
|
||||
|
||||
@@ -731,7 +784,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
|
||||
|
||||
#: src/components/PlaylistMembershipPanel.tsx:34
|
||||
#: src/pages/UserPlaylists.tsx:400
|
||||
#: src/pages/UserPublicProfile.tsx:971
|
||||
#: src/pages/UserPublicProfile.tsx:982
|
||||
msgid "No playlists yet."
|
||||
msgstr "Pas encore de collections."
|
||||
|
||||
@@ -739,14 +792,14 @@ msgstr "Pas encore de collections."
|
||||
msgid "No users match \"{q}\"."
|
||||
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1018
|
||||
#: src/pages/UserPublicProfile.tsx:1029
|
||||
msgid "Not following anyone yet."
|
||||
msgstr "Aucun abonnement pour le moment."
|
||||
|
||||
#: src/pages/Notifications.tsx:376
|
||||
#: src/pages/UserDumps.tsx:99
|
||||
#: src/pages/UserPublicProfile.tsx:1318
|
||||
#: src/pages/UserPublicProfile.tsx:1440
|
||||
#: src/pages/UserPublicProfile.tsx:1338
|
||||
#: src/pages/UserPublicProfile.tsx:1460
|
||||
#: src/pages/UserUpvoted.tsx:159
|
||||
msgid "Nothing here yet."
|
||||
msgstr "Rien ici pour l'instant."
|
||||
@@ -774,7 +827,7 @@ msgid "Page not found"
|
||||
msgstr "Page introuvable"
|
||||
|
||||
#: src/pages/UserLogin.tsx:74
|
||||
#: src/pages/UserPublicProfile.tsx:1125
|
||||
#: src/pages/UserPublicProfile.tsx:1136
|
||||
msgid "Password"
|
||||
msgstr "Mot de passe"
|
||||
|
||||
@@ -804,17 +857,17 @@ msgstr "Titre de la collection"
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:177
|
||||
#: src/pages/UserPlaylists.tsx:371
|
||||
#: src/pages/UserPublicProfile.tsx:890
|
||||
#: src/pages/UserPublicProfile.tsx:901
|
||||
msgid "Playlists"
|
||||
msgstr "Collections"
|
||||
|
||||
#. placeholder {0}: playlists.items.length
|
||||
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:940
|
||||
#: src/pages/UserPublicProfile.tsx:951
|
||||
msgid "Playlists ({0}{1})"
|
||||
msgstr "Collections ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:228
|
||||
#: src/components/DumpCreateModal.tsx:240
|
||||
msgid "Please select a file."
|
||||
msgstr "Veuillez sélectionner un fichier."
|
||||
|
||||
@@ -831,11 +884,11 @@ msgstr "Publier la réponse"
|
||||
msgid "Posting…"
|
||||
msgstr "Publication…"
|
||||
|
||||
#: src/components/DumpCard.tsx:114
|
||||
#: src/components/JournalCard.tsx:119
|
||||
#: src/components/DumpCard.tsx:119
|
||||
#: src/components/JournalCard.tsx:121
|
||||
#: src/components/PlaylistCard.tsx:73
|
||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||
#: src/pages/Dump.tsx:427
|
||||
#: src/pages/Dump.tsx:432
|
||||
#: src/pages/PlaylistDetail.tsx:644
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
@@ -855,11 +908,11 @@ msgstr "public"
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:232
|
||||
#: src/pages/DumpEdit.tsx:233
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Actualiser les métadonnées"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:231
|
||||
#: src/pages/DumpEdit.tsx:232
|
||||
msgid "Refreshing…"
|
||||
msgstr "Actualisation…"
|
||||
|
||||
@@ -877,7 +930,7 @@ msgstr "Inscription…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Inscription échouée"
|
||||
|
||||
#: src/pages/Dump.tsx:480
|
||||
#: src/pages/Dump.tsx:498
|
||||
msgid "Related"
|
||||
msgstr "Connexe"
|
||||
|
||||
@@ -897,7 +950,7 @@ msgstr "Retirer le j'aime"
|
||||
msgid "Remove vote"
|
||||
msgstr "Retirer le vote"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:413
|
||||
#: src/pages/DumpEdit.tsx:428
|
||||
msgid "Replace file"
|
||||
msgstr "Remplacer le fichier"
|
||||
|
||||
@@ -917,36 +970,38 @@ msgstr "Échec de la réinitialisation"
|
||||
msgid "Reset password"
|
||||
msgstr "Réinitialiser le mot de passe"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:364
|
||||
#: src/pages/DumpEdit.tsx:382
|
||||
#: src/pages/DumpEdit.tsx:379
|
||||
#: src/pages/DumpEdit.tsx:397
|
||||
msgid "Reset to default"
|
||||
msgstr "Réinitialiser par défaut"
|
||||
|
||||
#: src/pages/Dump.tsx:252
|
||||
#: src/pages/DumpEdit.tsx:171
|
||||
#: src/pages/Dump.tsx:254
|
||||
#: src/pages/DumpEdit.tsx:172
|
||||
msgid "Retry"
|
||||
msgstr "Réessayer"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1571
|
||||
#: src/pages/UserPublicProfile.tsx:1599
|
||||
msgid "Role"
|
||||
msgstr "Rôle"
|
||||
|
||||
#: src/components/CategoryManager.tsx:184
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:360
|
||||
#: src/pages/DumpEdit.tsx:446
|
||||
#: src/pages/Dump.tsx:365
|
||||
#: src/pages/DumpEdit.tsx:466
|
||||
#: src/pages/PlaylistDetail.tsx:927
|
||||
#: src/pages/UserPublicProfile.tsx:1638
|
||||
#: src/pages/UserPublicProfile.tsx:1708
|
||||
#: src/pages/UserPublicProfile.tsx:1666
|
||||
#: src/pages/UserPublicProfile.tsx:1736
|
||||
msgid "Save"
|
||||
msgstr "Enregistrer"
|
||||
|
||||
#: src/components/CategoryManager.tsx:181
|
||||
#: src/components/ChangePasswordModal.tsx:100
|
||||
#: src/components/CommentThread.tsx:329
|
||||
#: src/pages/Dump.tsx:359
|
||||
#: src/pages/Dump.tsx:364
|
||||
#: src/pages/PlaylistDetail.tsx:923
|
||||
#: src/pages/ResetPassword.tsx:126
|
||||
#: src/pages/UserPublicProfile.tsx:1635
|
||||
#: src/pages/UserPublicProfile.tsx:1705
|
||||
#: src/pages/UserPublicProfile.tsx:1663
|
||||
#: src/pages/UserPublicProfile.tsx:1733
|
||||
msgid "Saving…"
|
||||
msgstr "Enregistrement…"
|
||||
|
||||
@@ -985,7 +1040,7 @@ msgstr "Serveur inaccessible"
|
||||
msgid "Set new password"
|
||||
msgstr "Définir un nouveau mot de passe"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:894
|
||||
#: src/pages/UserPublicProfile.tsx:905
|
||||
msgid "Settings"
|
||||
msgstr "Paramètres"
|
||||
|
||||
@@ -994,7 +1049,7 @@ msgstr "Paramètres"
|
||||
msgid "Something went wrong"
|
||||
msgstr "Une erreur est survenue"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1144
|
||||
#: src/pages/UserPublicProfile.tsx:1155
|
||||
msgid "Style"
|
||||
msgstr "Style"
|
||||
|
||||
@@ -1018,17 +1073,17 @@ msgstr "Rien à voir, circulez."
|
||||
msgid "This reset link is missing or malformed."
|
||||
msgstr "Ce lien de réinitialisation est absent ou malformé."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:348
|
||||
#: src/pages/DumpEdit.tsx:363
|
||||
msgid "Thumbnail"
|
||||
msgstr "Miniature"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:386
|
||||
#: src/components/DumpCreateModal.tsx:399
|
||||
#: src/components/PlaylistCreateForm.tsx:70
|
||||
#: src/pages/DumpEdit.tsx:372
|
||||
#: src/pages/DumpEdit.tsx:387
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:229
|
||||
#: src/components/DumpCreateModal.tsx:241
|
||||
msgid "Title is required."
|
||||
msgstr "Un titre est requis."
|
||||
|
||||
@@ -1048,11 +1103,11 @@ msgstr "Ne plus suivre {targetUsername}"
|
||||
msgid "Unfollow playlist"
|
||||
msgstr "Ne plus suivre la collection"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:689
|
||||
#: src/pages/UserPublicProfile.tsx:697
|
||||
msgid "Upload failed"
|
||||
msgstr "Envoi échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:447
|
||||
#: src/components/DumpCreateModal.tsx:466
|
||||
msgid "Uploading…"
|
||||
msgstr "Envoi…"
|
||||
|
||||
@@ -1066,24 +1121,33 @@ msgstr "Voté"
|
||||
|
||||
#. placeholder {0}: votes.items.length
|
||||
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||
#: src/pages/UserPublicProfile.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:931
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Votés ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:335
|
||||
#: src/pages/DumpEdit.tsx:395
|
||||
#: src/components/DumpCreateModal.tsx:348
|
||||
#: src/pages/DumpEdit.tsx:410
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:216
|
||||
#: src/components/DumpCreateModal.tsx:227
|
||||
msgid "URL is required."
|
||||
msgstr "L'URL est obligatoire."
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1527
|
||||
#: src/components/CategoryManager.tsx:98
|
||||
#: src/components/CategoryManager.tsx:174
|
||||
msgid "URL slug"
|
||||
msgstr "Identifiant d’URL"
|
||||
|
||||
#: src/components/CategoryManager.tsx:97
|
||||
msgid "url-slug"
|
||||
msgstr "identifiant-url"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1547
|
||||
msgid "User"
|
||||
msgstr "Utilisateur"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1255
|
||||
#: src/pages/UserPublicProfile.tsx:1266
|
||||
msgid "User management"
|
||||
msgstr "Gestion utilisateur"
|
||||
|
||||
@@ -1100,30 +1164,30 @@ msgstr "Nom d'utilisateur"
|
||||
msgid "Users"
|
||||
msgstr "Utilisateurs"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:990
|
||||
#: src/pages/UserPublicProfile.tsx:1033
|
||||
#: src/pages/UserPublicProfile.tsx:1075
|
||||
#: src/pages/UserPublicProfile.tsx:1339
|
||||
#: src/pages/UserPublicProfile.tsx:1470
|
||||
#: src/pages/UserPublicProfile.tsx:1001
|
||||
#: src/pages/UserPublicProfile.tsx:1044
|
||||
#: src/pages/UserPublicProfile.tsx:1086
|
||||
#: src/pages/UserPublicProfile.tsx:1359
|
||||
#: src/pages/UserPublicProfile.tsx:1490
|
||||
msgid "View all →"
|
||||
msgstr "Tout voir →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:462
|
||||
#: src/components/DumpCreateModal.tsx:481
|
||||
msgid "View dump →"
|
||||
msgstr "Voir la reco →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:431
|
||||
#: src/pages/DumpEdit.tsx:425
|
||||
#: src/components/DumpCreateModal.tsx:444
|
||||
#: src/pages/DumpEdit.tsx:440
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "Pourquoi on en voudrait ?"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:874
|
||||
#: src/pages/UserPublicProfile.tsx:1697
|
||||
#: src/pages/UserPublicProfile.tsx:885
|
||||
#: src/pages/UserPublicProfile.tsx:1725
|
||||
msgid "Who am I?"
|
||||
msgstr "Qui suis-je ?"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:430
|
||||
#: src/pages/DumpEdit.tsx:424
|
||||
#: src/components/DumpCreateModal.tsx:443
|
||||
#: src/pages/DumpEdit.tsx:439
|
||||
msgid "Why?"
|
||||
msgstr "Pourquoi ?"
|
||||
|
||||
@@ -1139,7 +1203,7 @@ msgstr "Hier"
|
||||
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/FollowedFeed.tsx:115
|
||||
#: src/pages/index/FollowedFeed.tsx:116
|
||||
#: src/pages/index/HotFeed.tsx:69
|
||||
#: src/pages/index/JournalFeed.tsx:67
|
||||
#: src/pages/index/NewFeed.tsx:69
|
||||
|
||||
38
src/model.ts
38
src/model.ts
@@ -64,6 +64,7 @@ export interface Dump {
|
||||
commentCount: number;
|
||||
isPrivate: boolean;
|
||||
thumbnailMime?: string;
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
export type RawDump = WithStringDate<Dump>;
|
||||
@@ -80,6 +81,41 @@ export function hydrateDump(raw: unknown): Dump {
|
||||
return deserializeDump(raw as RawDump);
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories
|
||||
*/
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export type RawCategory = WithStringDate<Category>;
|
||||
|
||||
export function deserializeCategory(raw: RawCategory): Category {
|
||||
return {
|
||||
...raw,
|
||||
createdAt: new Date(raw.createdAt),
|
||||
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
slug: string;
|
||||
name: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
slug?: string;
|
||||
name?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Users
|
||||
*/
|
||||
@@ -540,6 +576,7 @@ export interface CreateUrlDumpRequest {
|
||||
url: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateDumpRequest {
|
||||
@@ -547,6 +584,7 @@ export interface UpdateDumpRequest {
|
||||
title?: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
export interface CreateCommentRequest {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../model.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
@@ -73,6 +74,7 @@ export function Dump() {
|
||||
const [titleError, setTitleError] = useState<string | null>(null);
|
||||
|
||||
const { user, token, authFetch } = useAuth();
|
||||
const { byId } = useCategories();
|
||||
const {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
@@ -267,6 +269,9 @@ export function Dump() {
|
||||
const { dump } = dumpState;
|
||||
const canEdit = !!user &&
|
||||
(dump.userId === user.id || can(user, "dump:moderate"));
|
||||
const dumpCategories = (dump.categoryIds ?? [])
|
||||
.map((id) => byId.get(id))
|
||||
.filter((c) => c !== undefined);
|
||||
|
||||
const handleTitleSave = async () => {
|
||||
const trimmed = titleDraft.trim();
|
||||
@@ -428,6 +433,19 @@ export function Dump() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{dumpCategories.length > 0 && (
|
||||
<div className="dump-card-categories">
|
||||
{dumpCategories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
to={`/${category.slug}`}
|
||||
className="dump-card-category-chip"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { FileDropZone } from "../components/FileDropZone.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import { CategorySelect } from "../components/CategorySelect.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
@@ -286,6 +287,15 @@ function DumpEditForm({
|
||||
onSaved,
|
||||
}: DumpEditFormProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
|
||||
() => new Set(dump.categoryIds ?? []),
|
||||
);
|
||||
const toggleCategory = (id: string) =>
|
||||
setSelectedCategoryIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
const form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: dump.title,
|
||||
@@ -306,11 +316,14 @@ function DumpEditForm({
|
||||
const isPrivate = !isPublic;
|
||||
let res: Response;
|
||||
|
||||
const categoryIds = [...selectedCategoryIds];
|
||||
|
||||
if (dump.kind === "file" && newFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", newFile);
|
||||
formData.append("title", trimmedTitle);
|
||||
if (comment.trim()) formData.append("comment", comment.trim());
|
||||
formData.append("categoryIds", JSON.stringify(categoryIds));
|
||||
res = await authFetch(`${API_URL}/api/dumps/${dump.id}/file`, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
@@ -322,11 +335,13 @@ function DumpEditForm({
|
||||
title: trimmedTitle,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
categoryIds,
|
||||
}
|
||||
: {
|
||||
title: trimmedTitle,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
categoryIds,
|
||||
};
|
||||
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
||||
method: "PUT",
|
||||
@@ -428,6 +443,11 @@ function DumpEditForm({
|
||||
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
|
||||
/>
|
||||
|
||||
<CategorySelect
|
||||
selected={selectedCategoryIds}
|
||||
onToggle={toggleCategory}
|
||||
/>
|
||||
|
||||
<VisibilityToggle<EditValues> name="isPublic" />
|
||||
|
||||
<div className="form-actions">
|
||||
|
||||
@@ -6,13 +6,12 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
import { PresenceRow } from "../components/PresenceRow.tsx";
|
||||
import { FeedTabBar } from "../components/FeedTabBar.tsx";
|
||||
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
|
||||
import { useTabParam } from "../hooks/useTabParam.ts";
|
||||
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
@@ -31,6 +30,7 @@ import { useScrollSave } from "../hooks/useScrollSave.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
|
||||
@@ -38,6 +38,7 @@ import { HotFeed } from "./index/HotFeed.tsx";
|
||||
import { NewFeed } from "./index/NewFeed.tsx";
|
||||
import { JournalFeed } from "./index/JournalFeed.tsx";
|
||||
import { FollowedFeed } from "./index/FollowedFeed.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
import type { MainFeedProps } from "./index/types.ts";
|
||||
|
||||
type DumpsState =
|
||||
@@ -51,13 +52,16 @@ type DumpsState =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
export function Index() {
|
||||
/**
|
||||
* The shared feed page. `categorySlug` scopes it to a single category; when
|
||||
* absent it shows every dump. The router keys this component by scope, so each
|
||||
* category gets its own mount/fetch while switching feed tabs reuses it.
|
||||
*/
|
||||
export function Index({ categorySlug }: { categorySlug?: string }) {
|
||||
const location = useLocation();
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||
?.deletedDumpId;
|
||||
|
||||
useDocumentTitle(null);
|
||||
|
||||
const { user, token } = useAuth();
|
||||
const {
|
||||
voteCounts,
|
||||
@@ -68,10 +72,42 @@ export function Index() {
|
||||
removeVote,
|
||||
} = useWS();
|
||||
|
||||
// Resolve the scope to a real category (for its id, name, and 404 handling).
|
||||
const { categories, isLoaded: categoriesLoaded } = useCategories();
|
||||
const category = categorySlug
|
||||
? categories.find((c) => c.slug === categorySlug)
|
||||
: undefined;
|
||||
const categoryNotFound = !!categorySlug && categoriesLoaded && !category;
|
||||
|
||||
useDocumentTitle(
|
||||
categorySlug ? (category ? category.name : undefined) : null,
|
||||
);
|
||||
|
||||
// ── Active feed tab (from the URL) ──
|
||||
|
||||
const { feedTab: rawTab } = useParams();
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? "hot"
|
||||
: preferredTab;
|
||||
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "")
|
||||
? (rawTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
// ── Main feed state ──
|
||||
|
||||
const feedUrl = useCallback(
|
||||
(page: number) =>
|
||||
categorySlug
|
||||
? `${API_URL}/api/categories/${
|
||||
encodeURIComponent(categorySlug)
|
||||
}/dumps?page=${page}&limit=${DEFAULT_PAGE_SIZE}`
|
||||
: `${API_URL}/api/dumps/?page=${page}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
[categorySlug],
|
||||
);
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:index:${user?.id ?? "guest"}`,
|
||||
`feed:index:${categorySlug ?? "all"}:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
|
||||
@@ -87,14 +123,6 @@ export function Index() {
|
||||
);
|
||||
const mainFetchDone = useRef(false);
|
||||
|
||||
// The preferred default tab decides which feed `/` opens on. "followed"
|
||||
// requires a session, so fall back to "hot" for guests.
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? "hot"
|
||||
: preferredTab;
|
||||
const [tab] = useTabParam<FeedTab>(FEED_TABS, defaultTab);
|
||||
|
||||
// Web Share Target: Android share sheet navigates to /?share_url=...
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const shareUrl = searchParams.get("share_url") ??
|
||||
@@ -102,9 +130,6 @@ export function Index() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareUrl) return;
|
||||
// Clean share params from the URL so a refresh doesn't re-open the modal.
|
||||
// The active tab already lives in the pathname (e.g. /~/new), so just drop
|
||||
// the query string.
|
||||
globalThis.history.replaceState({}, "", location.pathname);
|
||||
}, [shareUrl, location.pathname]);
|
||||
|
||||
@@ -116,13 +141,10 @@ export function Index() {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/dumps/?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
const res = await fetch(feedUrl(1), {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
@@ -142,7 +164,7 @@ export function Index() {
|
||||
mainFetchDone.current = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [cached, token]);
|
||||
}, [cached, token, feedUrl]);
|
||||
|
||||
// ── WS sync for main feed ──
|
||||
|
||||
@@ -166,7 +188,7 @@ export function Index() {
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||
fetch(feedUrl(nextPage), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
@@ -189,7 +211,7 @@ export function Index() {
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [dumpsState, token]);
|
||||
}, [dumpsState, token, feedUrl]);
|
||||
|
||||
// ── Scroll save / restore ──
|
||||
|
||||
@@ -227,13 +249,20 @@ export function Index() {
|
||||
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
|
||||
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
|
||||
|
||||
// New dumps arriving over the WS only belong in this feed if they're in scope.
|
||||
const categoryId = category?.id;
|
||||
const inScope = useCallback(
|
||||
(d: Dump) =>
|
||||
categorySlug ? !!categoryId && !!d.categoryIds?.includes(categoryId) : true,
|
||||
[categorySlug, categoryId],
|
||||
);
|
||||
|
||||
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
||||
const combined = useMemo(
|
||||
() =>
|
||||
[...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps].filter(
|
||||
(d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId,
|
||||
),
|
||||
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId],
|
||||
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
|
||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId),
|
||||
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope],
|
||||
);
|
||||
|
||||
const mainFeedProps: MainFeedProps = {
|
||||
@@ -252,6 +281,8 @@ export function Index() {
|
||||
|
||||
// ── Render ──
|
||||
|
||||
if (categoryNotFound) return <NotFound />;
|
||||
|
||||
return (
|
||||
<div className="index-page">
|
||||
<AppHeader
|
||||
@@ -275,6 +306,7 @@ export function Index() {
|
||||
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
|
||||
{tab === "followed" && user && (
|
||||
<FollowedFeed
|
||||
categorySlug={categorySlug}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { CategoryManager } from "../components/CategoryManager.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { useTheme } from "../hooks/useTheme.ts";
|
||||
@@ -227,9 +228,16 @@ export function UserPublicProfile() {
|
||||
// `settings` is own-profile only; `manage` requires the user:manage permission
|
||||
// on someone else's profile. Invalid tabs fall back to "dumps".
|
||||
const canManageUsers = can(me, "user:manage");
|
||||
const canManageCategories = can(me, "category:manage");
|
||||
// The "manage" tab is an admin/moderation area. On someone else's profile it
|
||||
// holds per-user tools (role management); on your own it holds global tools
|
||||
// (category management, with more to come).
|
||||
const showManageTab = isOwnProfile
|
||||
? canManageCategories
|
||||
: canManageUsers;
|
||||
const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
|
||||
if (t === "settings") return isOwnProfile;
|
||||
if (t === "manage") return !isOwnProfile && canManageUsers;
|
||||
if (t === "manage") return showManageTab;
|
||||
return true;
|
||||
});
|
||||
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
|
||||
@@ -780,7 +788,10 @@ export function UserPublicProfile() {
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<div className="profile-info-scroll">
|
||||
<h1 className="profile-username">{profileUser.username}</h1>
|
||||
<div className="profile-username-row">
|
||||
<h1 className="profile-username">{profileUser.username}</h1>
|
||||
<RoleChip role={profileUser.role} />
|
||||
</div>
|
||||
{profileUser.invitedByUsername
|
||||
? (
|
||||
<p className="profile-invited-by">
|
||||
@@ -893,7 +904,7 @@ export function UserPublicProfile() {
|
||||
...(isOwnProfile
|
||||
? [{ key: "settings" as const, label: <Trans>Settings</Trans> }]
|
||||
: []),
|
||||
...(!isOwnProfile && canManageUsers
|
||||
...(showManageTab
|
||||
? [{ key: "manage" as const, label: <Trans>Manage</Trans> }]
|
||||
: []),
|
||||
]}
|
||||
@@ -1263,6 +1274,15 @@ export function UserPublicProfile() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "manage" && isOwnProfile && canManageCategories && (
|
||||
<section className="profile-section">
|
||||
<h2 className="profile-section-title">
|
||||
<Trans>Categories</Trans>
|
||||
</h2>
|
||||
<CategoryManager />
|
||||
</section>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1529,6 +1549,14 @@ const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
|
||||
{ value: "admin", label: <Trans>Admin</Trans> },
|
||||
];
|
||||
|
||||
// Role badge shown next to the username. The default "user" role is implicit,
|
||||
// so only elevated roles get a chip to avoid labelling every profile.
|
||||
function RoleChip({ role }: { role: Role }) {
|
||||
if (role === "user") return null;
|
||||
const label = ROLE_OPTIONS.find((o) => o.value === role)?.label;
|
||||
return <span className={`profile-role-chip profile-role-chip--${role}`}>{label}</span>;
|
||||
}
|
||||
|
||||
interface RoleSwitcherProps {
|
||||
userId: string;
|
||||
currentRole: Role;
|
||||
|
||||
@@ -34,6 +34,7 @@ type FeedState =
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
interface FollowedFeedProps {
|
||||
categorySlug?: string;
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
user: User;
|
||||
@@ -122,6 +123,7 @@ function FollowedSubFeed({
|
||||
// ── FollowedFeed ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function FollowedFeed({
|
||||
categorySlug,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
user,
|
||||
@@ -131,14 +133,20 @@ export function FollowedFeed({
|
||||
}: FollowedFeedProps) {
|
||||
const { token } = useAuth();
|
||||
|
||||
// Scope key + query suffix applied to every followed-feed request.
|
||||
const scope = categorySlug ?? "all";
|
||||
const catQuery = categorySlug
|
||||
? `&category=${encodeURIComponent(categorySlug)}`
|
||||
: "";
|
||||
|
||||
const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>(
|
||||
`feed:followed-users:${user.id}`,
|
||||
`feed:followed-users:${scope}:${user.id}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
|
||||
Dump
|
||||
>(
|
||||
`feed:followed-playlists:${user.id}`,
|
||||
`feed:followed-playlists:${scope}:${user.id}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
@@ -192,7 +200,7 @@ export function FollowedFeed({
|
||||
if (!token) return;
|
||||
if (usersState.status === "loading") {
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
@@ -212,7 +220,7 @@ export function FollowedFeed({
|
||||
}
|
||||
if (playlistsState.status === "loading") {
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
@@ -267,7 +275,7 @@ export function FollowedFeed({
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
@@ -302,7 +310,7 @@ export function FollowedFeed({
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
|
||||
@@ -306,6 +306,17 @@
|
||||
box-shadow: 2px 2px 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Category management chips / inline editor ───────────────────── */
|
||||
[data-style="brutalist"] .category-chip,
|
||||
[data-style="brutalist"] .category-editor,
|
||||
[data-style="brutalist"] .category-editor-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
[data-style="brutalist"] .category-editor {
|
||||
box-shadow: 2px 2px 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Search bar ──────────────────────────────────────────────────── */
|
||||
[data-style="brutalist"] .search-bar-input,
|
||||
[data-style="brutalist"] .search-bar-btn {
|
||||
@@ -505,3 +516,26 @@
|
||||
[data-style="brutalist"] .error-card {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ── Category switcher dropdown ──────────────────────────────────── */
|
||||
/* The trigger reuses .feed-sort-btn, so it already wears this theme's button
|
||||
chrome and lines up with the sort tabs. The menu keeps the shared popover
|
||||
frame (matching .user-menu-dropdown here); only flatten its option buttons
|
||||
so they read as list rows rather than chunky bevelled buttons. */
|
||||
[data-style="brutalist"] .category-switcher-menu {
|
||||
border-radius: 0;
|
||||
}
|
||||
[data-style="brutalist"] .category-switcher-option {
|
||||
border: none !important;
|
||||
border-radius: 0;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
[data-style="brutalist"] .category-switcher-option:hover:not(:disabled) {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* ── Journal masonry cards ───────────────────────────────────────── */
|
||||
[data-style="brutalist"] .journal-card {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -311,7 +311,8 @@
|
||||
[data-style="geocities"] .nav-search-btn,
|
||||
[data-style="geocities"] .auth-link-btn,
|
||||
[data-style="geocities"] .form-cancel,
|
||||
[data-style="geocities"] .form-label-action {
|
||||
[data-style="geocities"] .form-label-action,
|
||||
[data-style="geocities"] .category-editor-btn {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@@ -488,10 +489,32 @@
|
||||
[data-style="geocities"] .md code,
|
||||
[data-style="geocities"] .md pre,
|
||||
[data-style="geocities"] .error-card,
|
||||
[data-style="geocities"] .feed-tab {
|
||||
[data-style="geocities"] .feed-tab,
|
||||
[data-style="geocities"] .category-chip,
|
||||
[data-style="geocities"] .category-editor,
|
||||
[data-style="geocities"] .category-editor-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* The editor is one squared box; flatten its fields (geocities otherwise
|
||||
forces a sunken inset border on every input) and drop the soft glow. */
|
||||
[data-style="geocities"] .category-editor {
|
||||
border: 2px solid var(--color-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .category-editor-input {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Chips are tags, not raised Win95 buttons — keep a clean flat border
|
||||
instead of the generic 3px outset bevel. */
|
||||
[data-style="geocities"] .category-chip {
|
||||
border: 2px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .notification-badge {
|
||||
border-radius: 0;
|
||||
animation: geo-blink 1.1s step-start infinite;
|
||||
@@ -520,3 +543,17 @@
|
||||
[data-style="geocities"] .invite-url {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* ── Category switcher dropdown ──────────────────────────────────── */
|
||||
/* Same ridged frame as the other popovers; option buttons flattened so the
|
||||
chunky Win95 button bevel doesn't apply to menu rows. (The trigger reuses
|
||||
.feed-sort-btn and keeps the bevel, matching the sort tabs.) */
|
||||
[data-style="geocities"] .category-switcher-menu {
|
||||
border-radius: 0;
|
||||
border: 3px ridge var(--color-border);
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--color-border) 45%, transparent);
|
||||
}
|
||||
[data-style="geocities"] .category-switcher-option {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -534,6 +534,17 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Category management chips / inline editor: squared, no accent glow. */
|
||||
[data-style="nyt"] .category-chip,
|
||||
[data-style="nyt"] .category-editor,
|
||||
[data-style="nyt"] .category-editor-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
[data-style="nyt"] .category-editor {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Links are now ink, so give the user-link classes a hover underline
|
||||
to keep them findable without colour. */
|
||||
[data-style="nyt"] .dump-op-link:hover,
|
||||
@@ -595,3 +606,12 @@
|
||||
[data-style="nyt"] .playlist-card-inner:hover .playlist-card-preview img {
|
||||
transform: scale(1.09);
|
||||
}
|
||||
|
||||
/* ── Category switcher dropdown — paper sheet, squared ───────────── */
|
||||
/* Match the other nyt popovers (1px rule + editorial shadow, no rounding).
|
||||
Option rows inherit the global square-off, so no per-row rule is needed. */
|
||||
[data-style="nyt"] .category-switcher-menu {
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface JournalEntry {
|
||||
/** A dump that can carry a real preview image (file image or rich thumbnail). */
|
||||
export function hasThumbnail(dump: Dump): boolean {
|
||||
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) return true;
|
||||
if (dump.thumbnailMime) return true;
|
||||
return !!dump.richContent?.thumbnailUrl;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@ export type Permission =
|
||||
| "dump:moderate"
|
||||
| "comment:moderate"
|
||||
| "playlist:moderate"
|
||||
| "user:manage";
|
||||
| "user:manage"
|
||||
| "category:manage";
|
||||
|
||||
const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"user:manage",
|
||||
"category:manage",
|
||||
];
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
|
||||
Reference in New Issue
Block a user