v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:44:14 +00:00
parent 07d0344be7
commit 2b6e68391b
242 changed files with 4390 additions and 4229 deletions

View File

@@ -2,8 +2,8 @@ import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { dumpThumbnailUrl, dumpUrl } from "../utils/shared/urls.ts";
import { can } from "../utils/auth/permissions.ts";
import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx";
import { API_URL, VALIDATION } from "../config/api.ts";
@@ -26,13 +26,13 @@ import {
parseAPIResponse,
} 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 { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts";
import { Avatar } from "../components/ui/Avatar.tsx";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useCategories } from "../hooks/category/useCategories.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { Avatar } from "../components/shared/Avatar.tsx";
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
import { FilePreview } from "../components/dump/FilePreview.tsx";
import { VoteButton } from "../components/social/VoteButton.tsx";
@@ -42,8 +42,8 @@ import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { Markdown } from "../components/editor/Markdown.tsx";
import { CommentThread } from "../components/comment/CommentThread.tsx";
import { Tooltip } from "../components/ui/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { Tooltip } from "../components/shared/Tooltip.tsx";
import { friendlyFetchError } from "../utils/shared/api.ts";
type DumpState =
| { status: "loading" }

View File

@@ -6,31 +6,33 @@ import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useRequiredAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { formatBytes } from "../utils/shared/format.ts";
import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
} from "../utils/shared/urls.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { ConfirmModal } from "../components/ui/ConfirmModal.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/shared/api.ts";
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
import { FilePreview } from "../components/dump/FilePreview.tsx";
import { ImagePicker } from "../components/ui/ImagePicker.tsx";
import { ImagePicker } from "../components/shared/ImagePicker.tsx";
import { CategorySelect } from "../components/category/CategorySelect.tsx";
import {
expectOk,
FileField,
FormError,
FormProvider,
RichTextField,
SubmitButton,
TextField,
useApiForm,
VisibilityToggle,
} from "../components/form/index.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { expectOk } from "../utils/shared/api.ts";
import { FileField } from "../components/form/FileField.tsx";
import { FormError } from "../components/form/FormError.tsx";
import { FormProvider } from "react-hook-form";
import { RichTextField } from "../components/form/RichTextField.tsx";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
import { VisibilityToggle } from "../components/form/SegmentedField.tsx";
import { useAuth } from "../hooks/auth/useAuth.ts";
type DumpEditState =
| { status: "loading" }

View File

@@ -9,14 +9,14 @@ import {
import { useLocation, useParams } from "react-router";
import { AppHeader } from "../components/layout/AppHeader.tsx";
import { PresenceRow } from "./index/PresenceRow.tsx";
import { FeedTabBar } from "./index/FeedTabBar.tsx";
import { PresenceRow } from "../components/feed/PresenceRow.tsx";
import { FeedTabBar } from "../components/feed/FeedTabBar.tsx";
import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
} from "../config/feedTabs.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
@@ -28,22 +28,20 @@ import {
type RawDump,
} from "../model.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
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";
import { friendlyFetchError } from "../utils/shared/api.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { useScrollSave } from "../hooks/feed/useScrollSave.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { useCategories } from "../hooks/category/useCategories.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
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 { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx";
import { JournalFeed } from "../components/feed/JournalFeed.tsx";
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
import { NotFound } from "./NotFound.tsx";
import type { MainFeedProps } from "./index/types.ts";
type DumpsState =
| { status: "loading" }
@@ -345,8 +343,9 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
<PresenceRow fitWithin=".index-below-header" fitBeside=".feed-sort" />
</div>
{tab === "hot" && <HotFeed {...mainFeedProps} />}
{tab === "new" && <NewFeed {...mainFeedProps} />}
{(tab === "hot" || tab === "new") && (
<DumpFeed order={tab} {...mainFeedProps} />
)}
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
{tab === "followed" && user && (
<FollowedFeed

View File

@@ -2,7 +2,7 @@ import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PageShell } from "../components/layout/PageShell.tsx";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
export function NotFound() {
useDocumentTitle(t`Page not found`);

View File

@@ -2,14 +2,14 @@ import React, { useEffect, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { relativeTime } from "../utils/relativeTime.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import { Tooltip } from "../components/ui/Tooltip.tsx";
import { useWS } from "../hooks/useWS.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { Tooltip } from "../components/shared/Tooltip.tsx";
import { useWS } from "../hooks/ws/useWS.ts";
import type {
CommentLikedData,
DumpCommentedData,
@@ -26,8 +26,8 @@ import type {
} from "../model.ts";
import { deserializeNotification } from "../model.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { expectOk } from "../components/form/index.ts";
import { friendlyFetchError } from "../utils/shared/api.ts";
import { expectOk } from "../utils/shared/api.ts";
type State =
// `arrived`: sent over the socket before the list came back — the list may

View File

@@ -9,7 +9,7 @@ import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "../components/ui/CountedInput.tsx";
import { CountedInput } from "../components/shared/CountedInput.tsx";
import type {
Playlist,
PlaylistWithDumps,
@@ -24,32 +24,29 @@ import {
deserializePlaylist,
deserializePlaylistWithDumps,
} from "../model.ts";
import { playlistImageUrl, playlistUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { playlistImageUrl, playlistUrl } from "../utils/shared/urls.ts";
import { can } from "../utils/auth/permissions.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import { ConfirmModal } from "../components/ui/ConfirmModal.tsx";
import { ImagePicker } from "../components/ui/ImagePicker.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
import { ImagePicker } from "../components/shared/ImagePicker.tsx";
import { Markdown } from "../components/editor/Markdown.tsx";
import { TextEditor } from "../components/editor/TextEditor.tsx";
import { FollowPlaylistButton } from "../components/social/FollowButton.tsx";
import { Tooltip } from "../components/ui/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { Controller } from "react-hook-form";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
useApiForm,
} from "../components/form/index.ts";
import { Tooltip } from "../components/shared/Tooltip.tsx";
import { friendlyFetchError } from "../utils/shared/api.ts";
import { Controller, FormProvider } from "react-hook-form";
import { expectOk } from "../utils/shared/api.ts";
import { FormError } from "../components/form/FormError.tsx";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
type LoadState =
| { status: "loading" }

View File

@@ -4,16 +4,14 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
import { expectOk } from "../utils/shared/api.ts";
import { FormError } from "../components/form/FormError.tsx";
import { FormProvider } from "react-hook-form";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
interface Values {
newPassword: string;

View File

@@ -1,6 +1,6 @@
import { Navigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
export function RestrictedGuest({ children }: { children: React.ReactNode }) {
const { user } = useAuth();

View File

@@ -1,6 +1,6 @@
import { Navigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
export function RestrictedLoggedIn(
{ children }: { children: React.ReactNode },

View File

@@ -3,15 +3,15 @@ import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AppHeader } from "../components/layout/AppHeader.tsx";
import { TabBar } from "../components/ui/TabBar.tsx";
import { TabBar } from "../components/shared/TabBar.tsx";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useTabParam } from "../hooks/useTabParam.ts";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
import { useTabParam } from "../hooks/shared/useTabParam.ts";
import {
deserializeDump,
deserializePlaylist,
@@ -24,7 +24,7 @@ import {
type RawPublicUser,
} from "../model.ts";
import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import { friendlyFetchError } from "../utils/shared/api.ts";
const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
type Tab = (typeof SEARCH_TABS)[number];

View File

@@ -3,12 +3,12 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts";
import { useUserDumpFeed } from "../hooks/dump/useUserDumpFeed.ts";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { DumpCreateModal } from "../components/dump/create/DumpCreateModal.tsx";
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";

View File

@@ -9,18 +9,16 @@ import {
type LoginRequest,
type RawAuthResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
import { expectOk } from "../utils/shared/api.ts";
import { FormError } from "../components/form/FormError.tsx";
import { FormProvider } from "react-hook-form";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
interface LoginValues {
username: string;

View File

@@ -10,7 +10,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import { friendlyFetchError } from "../utils/shared/api.ts";
import type {
PaginatedData,
Playlist,
@@ -22,22 +22,22 @@ import {
deserializePublicUser,
hydratePlaylist,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useScrollSave } from "../hooks/useScrollSave.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { useScrollSave } from "../hooks/feed/useScrollSave.ts";
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx";
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
import { ConfirmModal } from "../components/ui/ConfirmModal.tsx";
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import { expectOk } from "../components/form/index.ts";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { expectOk } from "../utils/shared/api.ts";
interface PlaylistFeed {
items: Playlist[];

View File

@@ -30,46 +30,43 @@ import {
type RawPublicUser,
type UpdateUserRequest,
} from "../model.ts";
import { Avatar } from "../components/ui/Avatar.tsx";
import { Avatar } from "../components/shared/Avatar.tsx";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { CategoryManager } from "../components/category/CategoryManager.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useTheme } from "../hooks/useTheme.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { useSoundsEnabled } from "../hooks/useSoundsEnabled.ts";
import { previewChime } from "../utils/sound.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useTheme } from "../hooks/theme/useTheme.ts";
import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
import { useSoundsEnabled } from "../hooks/shared/useSoundsEnabled.ts";
import { previewChime } from "../utils/shared/sound.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { useFading } from "../hooks/feed/useFading.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { DumpCreateModal } from "../components/dump/create/DumpCreateModal.tsx";
import { FollowUserButton } from "../components/social/FollowButton.tsx";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { can } from "../utils/permissions.ts";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/shared/api.ts";
import { can } from "../utils/auth/permissions.ts";
import { TextEditor } from "../components/editor/TextEditor.tsx";
import { Markdown } from "../components/editor/Markdown.tsx";
import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx";
import { TabBar } from "../components/ui/TabBar.tsx";
import { useTabParam } from "../hooks/useTabParam.ts";
import { Controller } from "react-hook-form";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
useApiForm,
} from "../components/form/index.ts";
import { TabBar } from "../components/shared/TabBar.tsx";
import { useTabParam } from "../hooks/shared/useTabParam.ts";
import { Controller, FormProvider } from "react-hook-form";
import { expectOk } from "../utils/shared/api.ts";
import { FormError } from "../components/form/FormError.tsx";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
/**
* Own-profile action row: "Invite someone" then `children` (log out). The

View File

@@ -9,18 +9,16 @@ import {
type RawAuthResponse,
type RegisterRequest,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { expectOk } from "../utils/shared/api.ts";
import { FormError } from "../components/form/FormError.tsx";
import { FormProvider } from "react-hook-form";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
type TokenState =
| { status: "checking" }

View File

@@ -6,12 +6,12 @@ import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { deserializeDump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { useFading } from "../hooks/feed/useFading.ts";
import { useUserDumpFeed } from "../hooks/dump/useUserDumpFeed.ts";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
import { PageShell } from "../components/layout/PageShell.tsx";

View File

@@ -1,52 +0,0 @@
import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/useAuth.ts";
import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
} from "../../config/feedTabs.ts";
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
import { TabBar } from "../../components/ui/TabBar.tsx";
import { CategorySwitcher } from "../../components/category/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
? DEFAULT_FEED_TAB
: preferredTab;
const active: FeedTab =
(FEED_TABS as readonly string[]).includes(feedTab ?? "") &&
(feedTab !== "followed" || !!user)
? (feedTab as FeedTab)
: defaultTab;
const scope = categorySlug ?? "~";
const tabs = [
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
{ key: "new" as FeedTab, label: <Trans>New</Trans> },
{ key: "journal" as FeedTab, label: <Trans>Journal</Trans> },
...(user
? [{ key: "followed" as FeedTab, label: <Trans>Followed</Trans> }]
: []),
];
return (
<TabBar
tabs={tabs}
activeTab={active}
onChange={(t) => navigate(`/${scope}/${t}`)}
leading={<CategorySwitcher />}
/>
);
}

View File

@@ -1,385 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { TabBar } from "../../components/ui/TabBar.tsx";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/dump/DumpCard.tsx";
import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
import { useScrollSave } from "../../hooks/useScrollSave.ts";
import {
deserializeDump,
type Dump,
hydrateDump,
type PaginatedData,
type RawDump,
type User,
} from "../../model.ts";
import { friendlyFetchError } from "../../utils/apiError.ts";
type FeedState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
/** Page of a followed feed. Throws the server's own message on an error
* envelope, so it isn't mistaken for a lost connection. */
async function readPage(res: Response): Promise<PaginatedData<RawDump>> {
const body = await res.json();
if (!body.success) {
throw new Error(body.error?.message ?? `HTTP ${res.status}`);
}
return body.data as PaginatedData<RawDump>;
}
type FollowedSection = "users" | "playlists";
interface FollowedFeedProps {
categorySlug?: string;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
deletedDumpIds: Set<string>;
}
// ── FollowedSubFeed ───────────────────────────────────────────────────────────
interface FollowedSubFeedProps {
state: FeedState;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
deletedDumpIds: Set<string>;
emptyMessage: string;
onLoadMore: () => void;
}
function FollowedSubFeed({
state,
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
emptyMessage,
onLoadMore,
}: FollowedSubFeedProps) {
const enabled = state.status === "loaded" && state.hasMore &&
!state.loadingMore;
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
if (state.status === "loading") {
return (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (state.status === "error") {
return <ErrorCard title={t`Failed to load`} message={state.error} />;
}
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
if (visible.length === 0) {
return <p className="index-status">{emptyMessage}</p>;
}
return (
<>
<ul className="dump-feed">
{visible.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote
castVote={castVote}
removeVote={removeVote}
isOwner={user.id === dump.userId}
/>
))}
</ul>
{state.hasMore && <div ref={sentinelRef} />}
{state.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!state.hasMore && state.dumps.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);
}
// ── FollowedFeed ──────────────────────────────────────────────────────────────
export function FollowedFeed({
categorySlug,
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
}: 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:${scope}:${user.id}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Dump
>(
`feed:followed-playlists:${scope}:${user.id}`,
hydrateDump,
);
const [usersState, setUsersState] = useState<FeedState>(() =>
cachedUsers
? {
status: "loaded",
dumps: cachedUsers.items,
hasMore: cachedUsers.hasMore,
page: cachedUsers.page,
loadingMore: false,
}
: { status: "loading" }
);
const [playlistsState, setPlaylistsState] = useState<FeedState>(() =>
cachedPlaylists
? {
status: "loaded",
dumps: cachedPlaylists.items,
hasMore: cachedPlaylists.hasMore,
page: cachedPlaylists.page,
loadingMore: false,
}
: { status: "loading" }
);
const [section, setSection] = useState<FollowedSection>("users");
// WS sync
const setUsersItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setUsersState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setUsersItems);
const setPlaylistsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setPlaylistsState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setPlaylistsItems);
// Fetch on mount if not cached
useEffect(() => {
if (!token) return;
if (usersState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setUsersState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setUsersState({ status: "error", error: friendlyFetchError(err) })
);
}
if (playlistsState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setPlaylistsState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setPlaylistsState({
status: "error",
error: friendlyFetchError(err),
})
);
}
}, [token, usersState.status, playlistsState.status, catQuery]);
// Scroll save
useScrollSave(
usersState.status === "loaded",
useCallback((y) => {
if (usersState.status !== "loaded") return;
saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y);
}, [usersState, saveUsers]),
);
useScrollSave(
playlistsState.status === "loaded",
useCallback((y) => {
if (playlistsState.status !== "loaded") return;
savePlaylists(
playlistsState.dumps,
playlistsState.page,
playlistsState.hasMore,
y,
);
}, [playlistsState, savePlaylists]),
);
// Load-more callbacks
const loadMoreUsers = useCallback(() => {
if (
usersState.status !== "loaded" || !usersState.hasMore ||
usersState.loadingMore || !token
) return;
const nextPage = usersState.page + 1;
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setUsersState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [usersState, token, catQuery]);
const loadMorePlaylists = useCallback(() => {
if (
playlistsState.status !== "loaded" || !playlistsState.hasMore ||
playlistsState.loadingMore || !token
) return;
const nextPage = playlistsState.page + 1;
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setPlaylistsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [playlistsState, token, catQuery]);
return (
<div className="followed-feed">
<TabBar
tabs={[
{ key: "users", label: <Trans>From people</Trans> },
{ key: "playlists", label: <Trans>From playlists</Trans> },
]}
activeTab={section}
onChange={setSection}
innerClassName="followed-sub-nav"
/>
{section === "users" && (
<FollowedSubFeed
state={usersState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage={t`Follow some users to see their dumps here.`}
onLoadMore={loadMoreUsers}
/>
)}
{section === "playlists" && (
<FollowedSubFeed
state={playlistsState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage={t`Follow some public playlists to see their dumps here.`}
onLoadMore={loadMorePlaylists}
/>
)}
</div>
);
}

View File

@@ -1,74 +0,0 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/dump/DumpCard.tsx";
import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
import { hotScore } from "../../utils/hotScore.ts";
import type { MainFeedProps } from "./types.ts";
export function HotFeed(
{
dumps,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
}: MainFeedProps,
) {
const sorted = useMemo(
() => [...dumps].sort((a, b) => hotScore(b) - hotScore(a)),
[dumps],
);
if (loading) {
return (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) {
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
}
return (
<>
<ul className="dump-feed">
{sorted.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!hasMore && sorted.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);
}

View File

@@ -1,85 +0,0 @@
import { Fragment, useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
import { JournalCard } from "../../components/dump/JournalCard.tsx";
import { composeJournal } from "../../utils/journalLayout.ts";
import type { MainFeedProps } from "./types.ts";
export function JournalFeed(
{
dumps,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
}: MainFeedProps,
) {
const blocks = useMemo(() => composeJournal(dumps), [dumps]);
if (loading) {
return (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (blocks.length === 0) {
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
}
return (
<>
{
/* Each sub-block is its own grid, with a themable <hr> between them.
A break sits between two grids rather than inside one because a grid
item would be stretched to the 9rem row track — out here its height
is its own, down to nothing for a theme that wants no separation. */
}
<div className="journal-feed">
{blocks.map((block, index) => (
<Fragment key={block.key}>
{index > 0 && <hr className="journal-break" />}
<ul className="journal-grid">
{block.entries.map(({ dump, shape }) => (
<JournalCard
key={dump.id}
dump={dump}
shape={shape}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
</Fragment>
))}
</div>
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!hasMore && blocks.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);
}

View File

@@ -1,74 +0,0 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/dump/DumpCard.tsx";
import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
import type { MainFeedProps } from "./types.ts";
export function NewFeed(
{
dumps,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
}: MainFeedProps,
) {
const sorted = useMemo(
() =>
[...dumps].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
[dumps],
);
if (loading) {
return (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) {
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
}
return (
<>
<ul className="dump-feed">
{sorted.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!hasMore && sorted.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);
}

View File

@@ -1,74 +0,0 @@
import { useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Avatar } from "../../components/ui/Avatar.tsx";
import { useWSPresence } from "../../hooks/useWS.ts";
import { useRoomBeside } from "../../hooks/useRoomBeside.ts";
// Past this many, the row ends in a "+N" counter rather than growing, so a
// busy evening doesn't push the feed tabs out of the header.
const MAX_SEATS = 6;
// However tight the row, it keeps an avatar and the counter — past that, the
// tabs beside it scroll instead.
const MIN_SEATS = 2;
// Each seat (avatar or counter) is 32px wide and overlaps the one before it
// by 10px (.index-presence-avatar's negative margin), so adds 22px.
const SEAT = 32;
const SEAT_STEP = 22;
/**
* Who's online, as a row of overlapping avatars. Given `fitWithin` and
* `fitBeside`, it gives up seats to what's beside it — as many avatars as the
* room left by the tabs allows, the rest folded into the counter.
*/
export function PresenceRow(
{ fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string },
) {
const { onlineUsers } = useWSPresence();
const [el, setEl] = useState<HTMLDivElement | null>(null);
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });
if (onlineUsers.length === 0) return null;
const seats = room === null ? MAX_SEATS : Math.min(
MAX_SEATS,
Math.max(MIN_SEATS, 1 + Math.floor((room - SEAT) / SEAT_STEP)),
);
// The counter takes the last seat, so the row never runs past `seats`.
const shown = onlineUsers.length > seats
? onlineUsers.slice(0, seats - 1)
: onlineUsers;
const rest = onlineUsers.slice(shown.length);
const restNames = rest.map((u) => u.username).join(", ");
return (
<div className="index-presence" ref={setEl}>
{shown.map((u) => (
<Link
key={u.userId}
to={`/users/${u.username}`}
title={u.username}
className="index-presence-avatar"
>
<Avatar
userId={u.userId}
username={u.username}
hasAvatar={u.hasAvatar}
size={SEAT}
version={u.avatarVersion}
/>
</Link>
))}
{rest.length > 0 && (
<span
className="index-presence-avatar index-presence-more"
title={restNames}
aria-label={t`${rest.length} more online: ${restNames}`}
role="img"
>
+{rest.length}
</span>
)}
</div>
);
}

View File

@@ -1,16 +0,0 @@
import type { Ref } from "react";
import type { Dump, User } from "../../model.ts";
export interface MainFeedProps {
dumps: Dump[];
loading: boolean;
error: string | null;
hasMore: boolean;
loadingMore: boolean;
sentinelRef: Ref<HTMLDivElement>;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User | null;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
}