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:
@@ -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" }
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user