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

This commit is contained in:
khannurien
2026-06-28 16:23:44 +00:00
parent c8c7b05c25
commit fae25f3e6c
44 changed files with 2144 additions and 399 deletions

View File

@@ -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);
}

View File

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

Binary file not shown.

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

@@ -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 />}
/>
);
}

View File

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

View File

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

View File

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

View 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 () => {},
});

View 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>
);
}

View 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

View File

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

View File

@@ -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 lenregistrement 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 dURL"
#: 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

@@ -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[]> = {