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

@@ -124,29 +124,23 @@ api/
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed) uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
src/ # React frontend (Vite) src/ # React frontend (Vite)
config/ # API base URL, validation constants, feed tabs, upload limits config/ # API base URL, validation constants, feed tabs, upload limits
pages/ # Route-level components pages/ # One component per route, nothing else
index/ # Feed views (hot, new, followed, journal), feed tab bar, presence row components/ # React components only (.tsx)
components/ # Shared UI components, by feature contexts/ # FooContext.ts + FooProvider.tsx pairs
category/ # Category picker, switcher and manager hooks/ # Every hook (useFoo.ts)
chat/ # The chat modal and its message rows utils/ # Every non-React module: pure helpers, storage, API glue
comment/ # Comment threads, nodes and forms test/ # Frontend tests (Vitest): test/<layer>/<domain>/<Module>.test.tsx
dump/ # Dump cards, rich-content and file previews
create/ # The three-step new-dump modal
editor/ # Text editor, emoji picker, mentions, Markdown
form/ # Reusable form controls
layout/ # Header, page shell, floating buttons
player/ # Media player, global player, seek bar, waveform
playlist/ # Playlist cards, creation and membership
profile/ # Profile subpage header, password change
social/ # Vote, like and follow buttons, who-voted popover
ui/ # Generic pieces: modal, tooltip, tabs, avatar, thumbnails
test/ # Frontend tests (Vitest), mirroring src/
contexts/ # Auth, WebSocket, player, follows, theme
hooks/ # Data fetching and UI hooks
utils/ # Formatting, URL, hot-score, waveform, … helpers
locales/ # Lingui message catalogues (en, fr) locales/ # Lingui message catalogues (en, fr)
themes/ # Per-theme CSS files themes/ # Per-theme CSS files
model.ts # Shared frontend types model.ts # Shared frontend types
i18n.ts # Lingui runtime setup i18n.ts # Lingui runtime setup
public/ # Static assets (favicon, manifest, service worker) public/ # Static assets (favicon, manifest, service worker)
``` ```
Each of `components/`, `contexts/`, `hooks/` and `utils/` is split into the
same domain folders — `auth`, `category`, `chat`, `comment`, `dump`, `editor`,
`feed`, `form`, `layout`, `player`, `playlist`, `profile`, `social`, `theme`,
`ws` — plus `shared/` for what belongs to no single domain. So the chat lives in
`components/chat/`, `contexts/chat/`, `hooks/chat/` and `utils/chat/`, and its
tests in `test/<same path>`. Files are imported directly (no `index.ts`
barrels), and everything uses named exports.

View File

@@ -240,7 +240,7 @@ export async function createFileDump(
/** /**
* A file dump's version as the app puts it in file and thumbnail URLs * A file dump's version as the app puts it in file and thumbnail URLs
* (`?v=`): it changes whenever the file can have. Mirrored by * (`?v=`): it changes whenever the file can have. Mirrored by
* `dumpFileVersion` in src/utils/urls.ts. * `dumpFileVersion` in src/utils/shared/urls.ts.
*/ */
export function dumpFileVersion( export function dumpFileVersion(
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">, dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,

View File

@@ -10,17 +10,17 @@ import {
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx"; import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx"; import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
import { useAuth } from "./hooks/useAuth.ts"; import { useAuth } from "./hooks/auth/useAuth.ts";
import { useDefaultFeedTab } from "./hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "./hooks/feed/useDefaultFeedTab.ts";
import { DEFAULT_FEED_TAB } from "./config/feedTabs.ts"; import { DEFAULT_FEED_TAB } from "./config/feedTabs.ts";
import { AuthProvider } from "./contexts/AuthProvider.tsx"; import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
import { WSProvider } from "./contexts/WSProvider.tsx"; import { WSProvider } from "./contexts/ws/WSProvider.tsx";
import { FollowProvider } from "./contexts/FollowProvider.tsx"; import { FollowProvider } from "./contexts/social/FollowProvider.tsx";
import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx"; import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx";
import { ThemeProvider } from "./contexts/ThemeProvider.tsx"; import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx";
import { ChatProvider } from "./contexts/ChatProvider.tsx"; import { ChatProvider } from "./contexts/chat/ChatProvider.tsx";
import { GlobalPlayer } from "./components/player/GlobalPlayer.tsx"; import { GlobalPlayer } from "./components/player/GlobalPlayer.tsx";
import { DumpFab } from "./components/layout/DumpFab.tsx"; import { DumpFab } from "./components/layout/DumpFab.tsx";
import { ChatFab } from "./components/layout/ChatFab.tsx"; import { ChatFab } from "./components/layout/ChatFab.tsx";
@@ -181,7 +181,7 @@ function AppRoutes() {
); );
} }
function App() { export function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
@@ -203,5 +203,3 @@ function App() {
</ThemeProvider> </ThemeProvider>
); );
} }
export default App;

View File

@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useCategories } from "../../hooks/useCategories.ts"; import { useCategories } from "../../hooks/category/useCategories.ts";
/** /**
* Multi-select of categories rendered as toggle chips. Renders nothing when no * Multi-select of categories rendered as toggle chips. Renders nothing when no

View File

@@ -8,7 +8,7 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useCategories } from "../../hooks/useCategories.ts"; import { useCategories } from "../../hooks/category/useCategories.ts";
import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts"; import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts";
/** /**

View File

@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { COUNT_FROM, MAX_CHAT_LENGTH, sendable } from "./message.ts"; import {
COUNT_FROM,
MAX_CHAT_LENGTH,
sendable,
} from "../../utils/chat/message.ts";
export interface ChatEditBoxProps { export interface ChatEditBoxProps {
initial: string; initial: string;

View File

@@ -3,16 +3,16 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Link } from "react-router"; import { Link } from "react-router";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { ConfirmModal } from "../ui/ConfirmModal.tsx"; import { ConfirmModal } from "../shared/ConfirmModal.tsx";
import { Tooltip } from "../ui/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
import { useHeightTransition } from "../../hooks/useHeightTransition.ts"; import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { relativeTime } from "../../utils/relativeTime.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts";
import type { ChatMessage } from "../../model.ts"; import type { ChatMessage } from "../../model.ts";
import { ChatEditBox } from "./ChatEditBox.tsx"; import { ChatEditBox } from "./ChatEditBox.tsx";
import { ReplyRef } from "./ReplyRef.tsx"; import { ReplyRef } from "./ReplyRef.tsx";
import { messageTextClass } from "./message.ts"; import { messageTextClass } from "../../utils/chat/message.ts";
import { clockTime } from "./time.ts"; import { clockTime } from "../../utils/chat/time.ts";
export interface ChatMessageItemProps { export interface ChatMessageItemProps {
message: ChatMessage; message: ChatMessage;

View File

@@ -9,19 +9,19 @@ import {
} from "react"; } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { Modal } from "../ui/Modal.tsx"; import { Modal } from "../shared/Modal.tsx";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { useWS, useWSChat } from "../../hooks/useWS.ts"; import { useWS, useWSChat } from "../../hooks/ws/useWS.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { can } from "../../utils/permissions.ts"; import { can } from "../../utils/auth/permissions.ts";
import { readDraft, writeDraft } from "../../utils/chatDraft.ts"; import { readDraft, writeDraft } from "../../utils/chat/draft.ts";
import { mentionsUser } from "../../utils/chatText.ts"; import { mentionsUser } from "../../utils/chat/text.ts";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import type { import type {
ChatReplyTarget, ChatReplyTarget,
PendingChatMessage, PendingChatMessage,
} from "../../contexts/WSContext.ts"; } from "../../contexts/ws/WSContext.ts";
import { import {
type ChatMessage, type ChatMessage,
deserializeChatMessage, deserializeChatMessage,
@@ -29,8 +29,13 @@ import {
} from "../../model.ts"; } from "../../model.ts";
import { ChatMessageItem } from "./ChatMessageItem.tsx"; import { ChatMessageItem } from "./ChatMessageItem.tsx";
import { PendingItem } from "./PendingItem.tsx"; import { PendingItem } from "./PendingItem.tsx";
import { COUNT_FROM, MAX_CHAT_LENGTH, sendable, snippet } from "./message.ts"; import {
import { dayLabel, isSameDay } from "./time.ts"; COUNT_FROM,
MAX_CHAT_LENGTH,
sendable,
snippet,
} from "../../utils/chat/message.ts";
import { dayLabel, isSameDay } from "../../utils/chat/time.ts";
// Matches the server's default page size; a full page means there may be more. // Matches the server's default page size; a full page means there may be more.
const PAGE_SIZE = 50; const PAGE_SIZE = 50;

View File

@@ -1,10 +1,10 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import type { PendingChatMessage } from "../../contexts/WSContext.ts"; import type { PendingChatMessage } from "../../contexts/ws/WSContext.ts";
import type { PublicUser } from "../../model.ts"; import type { PublicUser } from "../../model.ts";
import { ReplyRef } from "./ReplyRef.tsx"; import { ReplyRef } from "./ReplyRef.tsx";
import { messageTextClass } from "./message.ts"; import { messageTextClass } from "../../utils/chat/message.ts";
export interface PendingItemProps { export interface PendingItemProps {
pending: PendingChatMessage; pending: PendingChatMessage;

View File

@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { snippet } from "./message.ts"; import { snippet } from "../../utils/chat/message.ts";
export interface ReplyRefProps { export interface ReplyRefProps {
replyToId: string; replyToId: string;

View File

@@ -1,14 +1,11 @@
import React from "react"; import React from "react";
import { Controller } from "react-hook-form"; import { Controller, FormProvider } from "react-hook-form";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { VALIDATION } from "../../config/api.ts"; import { VALIDATION } from "../../config/api.ts";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { import { FormError } from "../form/FormError.tsx";
FormError, import { SubmitButton } from "../form/SubmitButton.tsx";
FormProvider, import { useApiForm } from "../../hooks/form/useApiForm.ts";
SubmitButton,
useApiForm,
} from "../form/index.ts";
export interface CommentFormProps { export interface CommentFormProps {
/** Async submit; throw to surface a root error, resolve to clear/close. */ /** Async submit; throw to surface a root error, resolve to clear/close. */

View File

@@ -3,7 +3,7 @@ import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { can } from "../../utils/permissions.ts"; import { can } from "../../utils/auth/permissions.ts";
import type { import type {
Comment, Comment,
CreateCommentRequest, CreateCommentRequest,
@@ -12,17 +12,17 @@ import type {
User, User,
} from "../../model.ts"; } from "../../model.ts";
import { deserializeComment } from "../../model.ts"; import { deserializeComment } from "../../model.ts";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import type { TextEditorHandle } from "../editor/TextEditor.tsx"; import type { TextEditorHandle } from "../editor/TextEditor.tsx";
import { LikeButton } from "../social/LikeButton.tsx"; import { LikeButton } from "../social/LikeButton.tsx";
import { relativeTime } from "../../utils/relativeTime.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { Tooltip } from "../ui/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
import { ConfirmModal } from "../ui/ConfirmModal.tsx"; import { ConfirmModal } from "../shared/ConfirmModal.tsx";
import { useHeightTransition } from "../../hooks/useHeightTransition.ts"; import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { useWS } from "../../hooks/useWS.ts"; import { useWS } from "../../hooks/ws/useWS.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk } from "../form/index.ts"; import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx"; import { CommentForm } from "./CommentForm.tsx";
interface CommentNodeProps { interface CommentNodeProps {

View File

@@ -9,9 +9,9 @@ import type {
User, User,
} from "../../model.ts"; } from "../../model.ts";
import { deserializeComment } from "../../model.ts"; import { deserializeComment } from "../../model.ts";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk } from "../form/index.ts"; import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx"; import { CommentForm } from "./CommentForm.tsx";
import { CommentNode } from "./CommentNode.tsx"; import { CommentNode } from "./CommentNode.tsx";

View File

@@ -2,22 +2,22 @@ import { memo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../model.ts"; import type { Dump } from "../../model.ts";
import { relativeTime } from "../../utils/relativeTime.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts"; import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCategories } from "../../hooks/useCategories.ts"; import { useCategories } from "../../hooks/category/useCategories.ts";
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts"; import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import { import {
isDumpVisited, isDumpVisited,
isRecent, isRecent,
markDumpVisited, markDumpVisited,
} from "../../utils/visited.ts"; } from "../../utils/shared/visited.ts";
import { FilePreview } from "./FilePreview.tsx"; import { FilePreview } from "./FilePreview.tsx";
import { RichContentCard } from "./RichContentCard.tsx"; import { RichContentCard } from "./RichContentCard.tsx";
import { ThumbnailPlaceholder } from "../ui/ThumbnailPlaceholder.tsx"; import { ThumbnailPlaceholder } from "../shared/ThumbnailPlaceholder.tsx";
import { VoteButton } from "../social/VoteButton.tsx"; import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { Tooltip } from "../ui/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
interface DumpCardProps { interface DumpCardProps {
dump: Dump; dump: Dump;

View File

@@ -1,15 +1,19 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import type { Dump } from "../../model.ts"; import type { Dump } from "../../model.ts";
import { formatBytes } from "../../utils/format.ts"; import { formatBytes } from "../../utils/shared/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts"; import {
import { useAuth } from "../../hooks/useAuth.ts"; dumpFileUrl,
import { IconPause, IconPlay } from "../player/icons.tsx"; dumpThumbnailUrl,
dumpUrl,
} from "../../utils/shared/urls.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { IconPause, IconPlay } from "../player/PlayerIcons.tsx";
import { MediaPlayer } from "../player/MediaPlayer.tsx"; import { MediaPlayer } from "../player/MediaPlayer.tsx";
import { import {
PlayerContext, PlayerContext,
type PlayerItem, type PlayerItem,
PlayerTimeContext, PlayerTimeContext,
} from "../../contexts/PlayerContext.ts"; } from "../../contexts/player/PlayerContext.ts";
import { import {
BAR_GAP, BAR_GAP,
BAR_W, BAR_W,
@@ -17,7 +21,7 @@ import {
NUM_BARS, NUM_BARS,
VIEWBOX_W, VIEWBOX_W,
WAVEFORM_H, WAVEFORM_H,
} from "../../utils/waveform.ts"; } from "../../utils/player/waveform.ts";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";

View File

@@ -2,28 +2,28 @@ import { memo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../model.ts"; import type { Dump } from "../../model.ts";
import { relativeTime } from "../../utils/relativeTime.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts"; import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts"; import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import { import {
isDumpVisited, isDumpVisited,
isRecent, isRecent,
markDumpVisited, markDumpVisited,
} from "../../utils/visited.ts"; } from "../../utils/shared/visited.ts";
import { import {
hasQuote, hasQuote,
hasThumbnail, hasThumbnail,
type JournalShape, type JournalShape,
} from "../../utils/journalLayout.ts"; } from "../../utils/feed/journalLayout.ts";
import { VoteButton } from "../social/VoteButton.tsx"; import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { Tooltip } from "../ui/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
import { Thumbnail } from "../ui/Thumbnail.tsx"; import { Thumbnail } from "../shared/Thumbnail.tsx";
import { import {
canPlayRichContent, canPlayRichContent,
usePlayRichContent, usePlayRichContent,
} from "../../hooks/usePlayRichContent.ts"; } from "../../hooks/player/usePlayRichContent.ts";
export type { JournalShape }; export type { JournalShape };

View File

@@ -1,12 +1,12 @@
import { useContext } from "react"; import { useContext } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import type { RichContent } from "../../model.ts"; import type { RichContent } from "../../model.ts";
import { PlayerContext } from "../../contexts/PlayerContext.ts"; import { PlayerContext } from "../../contexts/player/PlayerContext.ts";
import { import {
canPlayRichContent, canPlayRichContent,
usePlayRichContent, usePlayRichContent,
} from "../../hooks/usePlayRichContent.ts"; } from "../../hooks/player/usePlayRichContent.ts";
import { Thumbnail } from "../ui/Thumbnail.tsx"; import { Thumbnail } from "../shared/Thumbnail.tsx";
interface RichContentCardProps { interface RichContentCardProps {
richContent: RichContent; richContent: RichContent;

View File

@@ -19,34 +19,32 @@ import {
deserializeDumpUrlMatch, deserializeDumpUrlMatch,
deserializePlaylistMembership, deserializePlaylistMembership,
} from "../../../model.ts"; } from "../../../model.ts";
import { useAuth } from "../../../hooks/useAuth.ts"; import { useAuth } from "../../../hooks/auth/useAuth.ts";
import { useWS } from "../../../hooks/useWS.ts"; import { useWS } from "../../../hooks/ws/useWS.ts";
import { dumpUrl, normalizeUrl } from "../../../utils/urls.ts"; import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts";
import { titleFromFileName } from "../../../utils/format.ts"; import { titleFromFileName } from "../../../utils/shared/format.ts";
import { import {
clearDumpDraft, clearDumpDraft,
type DumpDraft, type DumpDraft,
EMPTY_DUMP_DRAFT, EMPTY_DUMP_DRAFT,
loadDumpDraft, loadDumpDraft,
saveDumpDraft, saveDumpDraft,
} from "../../../utils/dumpDraft.ts"; } from "../../../utils/dump/draft.ts";
import { MAX_FILE_SIZE } from "../../../config/upload.ts"; import { MAX_FILE_SIZE } from "../../../config/upload.ts";
import { RichContentCard } from "../RichContentCard.tsx"; import { RichContentCard } from "../RichContentCard.tsx";
import type { RichContent } from "../../../model.ts"; import type { RichContent } from "../../../model.ts";
import { Modal } from "../../ui/Modal.tsx"; import { Modal } from "../../shared/Modal.tsx";
import type { TextEditorHandle } from "../../editor/TextEditor.tsx"; import type { TextEditorHandle } from "../../editor/TextEditor.tsx";
import { CategorySelect } from "../../category/CategorySelect.tsx"; import { CategorySelect } from "../../category/CategorySelect.tsx";
import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx"; import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx";
import { import { expectOk } from "../../../utils/shared/api.ts";
expectOk, import { FileField } from "../../form/FileField.tsx";
FileField, import { FormError } from "../../form/FormError.tsx";
FormError, import { FormProvider } from "react-hook-form";
FormProvider, import { RichTextField } from "../../form/RichTextField.tsx";
RichTextField, import { SubmitButton } from "../../form/SubmitButton.tsx";
SubmitButton, import { useApiForm } from "../../../hooks/form/useApiForm.ts";
useApiForm, import { VisibilityToggle } from "../../form/SegmentedField.tsx";
VisibilityToggle,
} from "../../form/index.ts";
import { DuplicateNotice } from "./DuplicateNotice.tsx"; import { DuplicateNotice } from "./DuplicateNotice.tsx";
import { LocalFilePreview } from "./LocalFilePreview.tsx"; import { LocalFilePreview } from "./LocalFilePreview.tsx";
import { type Step, StepIndicator } from "./StepIndicator.tsx"; import { type Step, StepIndicator } from "./StepIndicator.tsx";

View File

@@ -1,8 +1,8 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import type { DumpUrlMatch } from "../../../model.ts"; import type { DumpUrlMatch } from "../../../model.ts";
import { dumpUrl } from "../../../utils/urls.ts"; import { dumpUrl } from "../../../utils/shared/urls.ts";
import { relativeTime } from "../../../utils/relativeTime.ts"; import { relativeTime } from "../../../utils/shared/relativeTime.ts";
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */ /** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
export function DuplicateNotice( export function DuplicateNotice(

View File

@@ -12,7 +12,7 @@ import {
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react"; import { useLingui } from "@lingui/react";
import { readSkinTone, SKIN_TONE_KEY } from "./skinTone.ts"; import { readSkinTone, SKIN_TONE_KEY } from "../../utils/editor/skinTone.ts";
/** Remembers the skin tone picked in the selector for the next picker. */ /** Remembers the skin tone picked in the selector for the next picker. */
function SkinTonePersist() { function SkinTonePersist() {

View File

@@ -1,6 +1,6 @@
import { type CSSProperties, useLayoutEffect, useRef } from "react"; import { type CSSProperties, useLayoutEffect, useRef } from "react";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import type { UserResult } from "../../hooks/useMentionAutocomplete.ts"; import type { UserResult } from "../../hooks/editor/useMentionAutocomplete.ts";
interface MentionDropdownProps { interface MentionDropdownProps {
/** For the textarea's `aria-controls` / `aria-activedescendant`. */ /** For the textarea's `aria-controls` / `aria-activedescendant`. */

View File

@@ -11,13 +11,13 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { charCountClass } from "../../utils/charCount.ts"; import { charCountClass } from "../../utils/shared/charCount.ts";
import { EmojiPickerPopup } from "./EmojiPickerPopup.tsx"; import { EmojiPickerPopup } from "./EmojiPickerPopup.tsx";
import { MentionDropdown } from "./MentionDropdown.tsx"; import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts"; import { useMentionAutocomplete } from "../../hooks/editor/useMentionAutocomplete.ts";
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts"; import { useEmojiTrigger } from "../../hooks/editor/useEmojiTrigger.ts";
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts"; import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
export interface TextEditorHandle { export interface TextEditorHandle {

View File

@@ -1,12 +1,30 @@
import { useMemo } from "react"; import { type Ref, useMemo } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/dump/DumpCard.tsx"; import { DumpCard } from "../dump/DumpCard.tsx";
import { ErrorCard } from "../../components/ui/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
import type { MainFeedProps } from "./types.ts"; import type { Dump, User } from "../../model.ts";
import { hotScore } from "../../utils/feed/hotScore.ts";
export function NewFeed( // What the index hands whichever main feed its tab shows.
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;
}
// The hot and new tabs: the same list, ordered by hot score or by date.
export function DumpFeed(
{ {
order,
dumps, dumps,
loading, loading,
error, error,
@@ -18,12 +36,16 @@ export function NewFeed(
user, user,
castVote, castVote,
removeVote, removeVote,
}: MainFeedProps, }: MainFeedProps & { order: "hot" | "new" },
) { ) {
const sorted = useMemo( const sorted = useMemo(
() => () =>
[...dumps].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), [...dumps].sort((a, b) =>
[dumps], order === "hot"
? hotScore(b) - hotScore(a)
: b.createdAt.getTime() - a.createdAt.getTime()
),
[dumps, order],
); );
if (loading) { if (loading) {

View File

@@ -1,14 +1,14 @@
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { import {
DEFAULT_FEED_TAB, DEFAULT_FEED_TAB,
FEED_TABS, FEED_TABS,
type FeedTab, type FeedTab,
} from "../../config/feedTabs.ts"; } from "../../config/feedTabs.ts";
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
import { TabBar } from "../../components/ui/TabBar.tsx"; import { TabBar } from "../shared/TabBar.tsx";
import { CategorySwitcher } from "../../components/category/CategorySwitcher.tsx"; import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
/** /**
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the * Feed sort tabs (hot/new/journal/followed). Navigation stays within the

View File

@@ -1,15 +1,15 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { TabBar } from "../../components/ui/TabBar.tsx"; import { TabBar } from "../shared/TabBar.tsx";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/dump/DumpCard.tsx"; import { DumpCard } from "../dump/DumpCard.tsx";
import { ErrorCard } from "../../components/ui/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts"; import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
import { useScrollSave } from "../../hooks/useScrollSave.ts"; import { useScrollSave } from "../../hooks/feed/useScrollSave.ts";
import { import {
deserializeDump, deserializeDump,
type Dump, type Dump,
@@ -18,7 +18,7 @@ import {
type RawDump, type RawDump,
type User, type User,
} from "../../model.ts"; } from "../../model.ts";
import { friendlyFetchError } from "../../utils/apiError.ts"; import { friendlyFetchError } from "../../utils/shared/api.ts";
type FeedState = type FeedState =
| { status: "loading" } | { status: "loading" }

View File

@@ -1,10 +1,10 @@
import { Fragment, useMemo } from "react"; import { Fragment, useMemo } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ui/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
import { JournalCard } from "../../components/dump/JournalCard.tsx"; import { JournalCard } from "../dump/JournalCard.tsx";
import { composeJournal } from "../../utils/journalLayout.ts"; import { composeJournal } from "../../utils/feed/journalLayout.ts";
import type { MainFeedProps } from "./types.ts"; import type { MainFeedProps } from "./DumpFeed.tsx";
export function JournalFeed( export function JournalFeed(
{ {

View File

@@ -1,9 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Avatar } from "../../components/ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { useWSPresence } from "../../hooks/useWS.ts"; import { useWSPresence } from "../../hooks/ws/useWS.ts";
import { useRoomBeside } from "../../hooks/useRoomBeside.ts"; import { useRoomBeside } from "../../hooks/shared/useRoomBeside.ts";
// Past this many, the row ends in a "+N" counter rather than growing, so a // 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. // busy evening doesn't push the feed tabs out of the header.

View File

@@ -1,7 +1,7 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { formatBytes } from "../../utils/format.ts"; import { formatBytes } from "../../utils/shared/format.ts";
function fileIcon(mime: string): string { function fileIcon(mime: string): string {
if (mime.startsWith("image/")) return "🖼"; if (mime.startsWith("image/")) return "🖼";

View File

@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useFormState } from "react-hook-form"; import { useFormState } from "react-hook-form";
import { ErrorCard } from "../ui/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
/** /**
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a * Renders the form's root error (set by {@link useApiForm}'s `submit` on a

View File

@@ -7,7 +7,7 @@ import {
useWatch, useWatch,
} from "react-hook-form"; } from "react-hook-form";
import { charCountClass } from "../../utils/charCount.ts"; import { charCountClass } from "../../utils/shared/charCount.ts";
interface TextFieldProps<T extends FieldValues> { interface TextFieldProps<T extends FieldValues> {
name: Path<T>; name: Path<T>;

View File

@@ -1,9 +0,0 @@
export { expectOk, useApiForm } from "./useApiForm.ts";
export { TextField } from "./TextField.tsx";
export { RichTextField } from "./RichTextField.tsx";
export { SegmentedField, VisibilityToggle } from "./SegmentedField.tsx";
export { FileField } from "./FileField.tsx";
export { SubmitButton } from "./SubmitButton.tsx";
export { FormActions } from "./FormActions.tsx";
export { FormError } from "./FormError.tsx";
export { FormProvider } from "react-hook-form";

View File

@@ -2,13 +2,13 @@ import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useWS } from "../../hooks/useWS.ts"; import { useWS } from "../../hooks/ws/useWS.ts";
import { NotificationBell } from "./NotificationBell.tsx"; import { NotificationBell } from "./NotificationBell.tsx";
import { ChatButton } from "./ChatButton.tsx"; import { ChatButton } from "./ChatButton.tsx";
import { UserMenu } from "./UserMenu.tsx"; import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx"; import { SearchBar } from "./SearchBar.tsx";
import { SITE_EMOJI, SITE_NAME } from "../../hooks/useDocumentTitle.ts"; import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
const DumpCreateModal = lazy(() => const DumpCreateModal = lazy(() =>
import("../dump/create/DumpCreateModal.tsx").then((m) => ({ import("../dump/create/DumpCreateModal.tsx").then((m) => ({

View File

@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useWSChat } from "../../hooks/useWS.ts"; import { useWSChat } from "../../hooks/ws/useWS.ts";
import { useChat } from "../../hooks/useChat.ts"; import { useChat } from "../../hooks/chat/useChat.ts";
/** Header chat button with an unread-message badge. Opens the app-wide chat. */ /** Header chat button with an unread-message badge. Opens the app-wide chat. */
export function ChatButton() { export function ChatButton() {

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useWSChat } from "../../hooks/useWS.ts"; import { useWSChat } from "../../hooks/ws/useWS.ts";
import { useChat } from "../../hooks/useChat.ts"; import { useChat } from "../../hooks/chat/useChat.ts";
/** /**
* Floating chat button shown once the header has scrolled out of view, mirroring * Floating chat button shown once the header has scrolled out of view, mirroring

View File

@@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
const DumpCreateModal = lazy(() => const DumpCreateModal = lazy(() =>
import("../dump/create/DumpCreateModal.tsx").then((m) => ({ import("../dump/create/DumpCreateModal.tsx").then((m) => ({

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useWS } from "../../hooks/useWS.ts"; import { useWS } from "../../hooks/ws/useWS.ts";
export function NotificationBell() { export function NotificationBell() {
const { unreadNotificationCount, lastNotification } = useWS(); const { unreadNotificationCount, lastNotification } = useWS();

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { PageShell } from "./PageShell.tsx"; import { PageShell } from "./PageShell.tsx";
import { ErrorCard } from "../ui/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
export function PageError( export function PageError(
{ title, message, actions }: { { title, message, actions }: {

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import type { User } from "../../model.ts"; import type { User } from "../../model.ts";
export function UserMenu({ user }: { user: User }) { export function UserMenu({ user }: { user: User }) {

View File

@@ -6,12 +6,12 @@ import {
PlayerContext, PlayerContext,
type PlayerItem, type PlayerItem,
playerItemKey, playerItemKey,
} from "../../contexts/PlayerContext.ts"; } from "../../contexts/player/PlayerContext.ts";
import { MediaPlayer } from "./MediaPlayer.tsx"; import { MediaPlayer } from "./MediaPlayer.tsx";
import { fmt } from "../../utils/duration.ts"; import { fmt } from "../../utils/player/duration.ts";
import { Thumbnail } from "../ui/Thumbnail.tsx"; import { Thumbnail } from "../shared/Thumbnail.tsx";
import { useYouTubePlaylist } from "../../hooks/useYouTubePlaylist.ts"; import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
import { embedListId, withJsApi } from "../../utils/youtube.ts"; import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>; type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;

View File

@@ -1,10 +1,15 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { fmt } from "../../utils/duration.ts"; import { fmt } from "../../utils/player/duration.ts";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { IconFullscreen, IconPause, IconPlay, IconVolume } from "./icons.tsx"; import {
IconFullscreen,
IconPause,
IconPlay,
IconVolume,
} from "./PlayerIcons.tsx";
import { SeekBar } from "./SeekBar.tsx"; import { SeekBar } from "./SeekBar.tsx";
import { Waveform } from "./Waveform.tsx"; import { Waveform } from "./Waveform.tsx";
import { rememberVolume, storedVolume } from "./volume.ts"; import { rememberVolume, storedVolume } from "../../utils/player/volume.ts";
const HIDE_DELAY = 2500; const HIDE_DELAY = 2500;

View File

@@ -6,7 +6,7 @@ import {
NUM_BARS, NUM_BARS,
VIEWBOX_W, VIEWBOX_W,
WAVEFORM_H, WAVEFORM_H,
} from "../../utils/waveform.ts"; } from "../../utils/player/waveform.ts";
export function Waveform( export function Waveform(
{ src, current, duration, onSeek }: { { src, current, duration, onSeek }: {

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import type { PlaylistMembership, RawPlaylistMembership } from "../../model.ts"; import type { PlaylistMembership, RawPlaylistMembership } from "../../model.ts";
import { deserializePlaylistMembership } from "../../model.ts"; import { deserializePlaylistMembership } from "../../model.ts";
import { Modal } from "../ui/Modal.tsx"; import { Modal } from "../shared/Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
interface AddToPlaylistModalProps { interface AddToPlaylistModalProps {

View File

@@ -2,7 +2,7 @@ import { useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import type { Playlist } from "../../model.ts"; import type { Playlist } from "../../model.ts";
import { Modal } from "../ui/Modal.tsx"; import { Modal } from "../shared/Modal.tsx";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface NewPlaylistButtonProps { interface NewPlaylistButtonProps {

View File

@@ -2,14 +2,14 @@ import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import type { Playlist } from "../../model.ts"; import type { Playlist } from "../../model.ts";
import { relativeTime } from "../../utils/relativeTime.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { playlistImageUrl, playlistUrl } from "../../utils/urls.ts"; import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
import { import {
isPlaylistVisited, isPlaylistVisited,
isRecent, isRecent,
markPlaylistVisited, markPlaylistVisited,
} from "../../utils/visited.ts"; } from "../../utils/shared/visited.ts";
import { Tooltip } from "../ui/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
interface PlaylistCardProps { interface PlaylistCardProps {
playlist: Playlist; playlist: Playlist;

View File

@@ -7,18 +7,16 @@ import type {
RawPlaylist, RawPlaylist,
} from "../../model.ts"; } from "../../model.ts";
import { deserializePlaylist } from "../../model.ts"; import { deserializePlaylist } from "../../model.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { import { expectOk } from "../../utils/shared/api.ts";
expectOk, import { FormActions } from "../form/FormActions.tsx";
FormActions, import { FormError } from "../form/FormError.tsx";
FormError, import { FormProvider } from "react-hook-form";
FormProvider, import { RichTextField } from "../form/RichTextField.tsx";
RichTextField, import { SubmitButton } from "../form/SubmitButton.tsx";
SubmitButton, import { TextField } from "../form/TextField.tsx";
TextField, import { useApiForm } from "../../hooks/form/useApiForm.ts";
useApiForm, import { VisibilityToggle } from "../form/SegmentedField.tsx";
VisibilityToggle,
} from "../form/index.ts";
interface PlaylistCreateFormProps { interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */ /** If provided, the new playlist will have this dump added to it. */

View File

@@ -2,17 +2,15 @@ import { useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../../config/api.ts"; import { API_URL, VALIDATION } from "../../config/api.ts";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { Modal } from "../ui/Modal.tsx"; import { Modal } from "../shared/Modal.tsx";
import { import { expectOk } from "../../utils/shared/api.ts";
expectOk, import { FormActions } from "../form/FormActions.tsx";
FormActions, import { FormError } from "../form/FormError.tsx";
FormError, import { FormProvider } from "react-hook-form";
FormProvider, import { SubmitButton } from "../form/SubmitButton.tsx";
SubmitButton, import { TextField } from "../form/TextField.tsx";
TextField, import { useApiForm } from "../../hooks/form/useApiForm.ts";
useApiForm,
} from "../form/index.ts";
interface ChangePasswordModalProps { interface ChangePasswordModalProps {
onClose: () => void; onClose: () => void;

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import type { PublicUser } from "../../model.ts"; import type { PublicUser } from "../../model.ts";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
interface ProfileSubpageHeaderProps { interface ProfileSubpageHeaderProps {
username: string; username: string;

View File

@@ -1,6 +1,6 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts"; import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
interface AvatarProps { interface AvatarProps {
userId: string; userId: string;

View File

@@ -2,7 +2,7 @@ import { useEffect, useId } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useScrollLock } from "../../hooks/useScrollLock.ts"; import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
interface ConfirmModalProps { interface ConfirmModalProps {
message: string; message: string;

View File

@@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { charCountClass } from "../../utils/charCount.ts"; import { charCountClass } from "../../utils/shared/charCount.ts";
interface CountedInputProps interface CountedInputProps
extends extends

View File

@@ -1,7 +1,7 @@
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react"; import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useScrollLock } from "../../hooks/useScrollLock.ts"; import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
interface ModalProps { interface ModalProps {
title: string; title: string;

View File

@@ -1,5 +1,5 @@
import { type ComponentProps, useState } from "react"; import { type ComponentProps, useState } from "react";
import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts"; import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx"; import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx";
interface ThumbnailProps { interface ThumbnailProps {

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { proxyImageUrl } from "../../utils/proxyImage.ts"; import { proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { initialsFor, tintFor } from "../../utils/thumbnailTint.ts"; import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts";
interface ThumbnailPlaceholderProps { interface ThumbnailPlaceholderProps {
/** The dumped URL — seeds the fallback hue and the initial. */ /** The dumped URL — seeds the fallback hue and the initial. */

View File

@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useFollows } from "../../hooks/useFollows.ts"; import { useFollows } from "../../hooks/social/useFollows.ts";
interface FollowUserButtonProps { interface FollowUserButtonProps {
targetUserId: string; targetUserId: string;

View File

@@ -3,8 +3,8 @@ import { createPortal } from "react-dom";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Avatar } from "../ui/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { useAuth } from "../../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { import {
deserializePublicUser, deserializePublicUser,

View File

@@ -1,6 +1,6 @@
import { createContext, type Dispatch, type SetStateAction } from "react"; import { createContext, type Dispatch, type SetStateAction } from "react";
import { type AuthResponse } from "../model.ts"; import { type AuthResponse } from "../../model.ts";
export interface AuthContextValue { export interface AuthContextValue {
authResponse: AuthResponse | null; authResponse: AuthResponse | null;

View File

@@ -2,14 +2,14 @@ import { type ReactNode, useEffect, useState } from "react";
import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
import { type AuthResponse, deserializePublicUser } from "../model.ts"; import { type AuthResponse, deserializePublicUser } from "../../model.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { import {
AUTH_STORAGE_KEY, AUTH_STORAGE_KEY,
forgetStoredSession, forgetStoredSession,
readStoredSession, readStoredSession,
storeSession, storeSession,
} from "../utils/authStorage.ts"; } from "../../utils/auth/storage.ts";
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState<AuthResponse | null>( const [authResponse, setAuthResponse] = useState<AuthResponse | null>(

View File

@@ -1,5 +1,5 @@
import { createContext } from "react"; import { createContext } from "react";
import type { Category } from "../model.ts"; import type { Category } from "../../model.ts";
export interface CategoriesContextValue { export interface CategoriesContextValue {
categories: Category[]; categories: Category[];

View File

@@ -9,12 +9,12 @@ import {
CategoriesContext, CategoriesContext,
type CategoriesContextValue, type CategoriesContextValue,
} from "./CategoriesContext.ts"; } from "./CategoriesContext.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { import {
type Category, type Category,
deserializeCategory, deserializeCategory,
type RawCategory, type RawCategory,
} from "../model.ts"; } from "../../model.ts";
async function fetchCategories( async function fetchCategories(
signal?: AbortSignal, signal?: AbortSignal,

View File

@@ -7,11 +7,11 @@ import {
useState, useState,
} from "react"; } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { ChatContext, type ChatContextValue } from "./ChatContext.ts"; import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
const ChatModal = lazy(() => const ChatModal = lazy(() =>
import("../components/chat/ChatModal.tsx").then((m) => ({ import("../../components/chat/ChatModal.tsx").then((m) => ({
default: m.ChatModal, default: m.ChatModal,
})) }))
); );

View File

@@ -5,8 +5,11 @@ import {
playerItemKey, playerItemKey,
PlayerTimeContext, PlayerTimeContext,
} from "./PlayerContext.ts"; } from "./PlayerContext.ts";
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts"; import {
import { useAuth } from "../hooks/useAuth.ts"; isStreamStale,
resolveStreamQueue,
} from "../../utils/player/streamSources.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
const STORAGE_KEY = "player"; const STORAGE_KEY = "player";

View File

@@ -6,9 +6,9 @@ import {
useState, useState,
} from "react"; } from "react";
import { FollowContext, type FollowContextValue } from "./FollowContext.ts"; import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import type { FollowStatus } from "../model.ts"; import type { FollowStatus } from "../../model.ts";
export function FollowProvider({ children }: { children: ReactNode }) { export function FollowProvider({ children }: { children: ReactNode }) {
const { token, authFetch } = useAuth(); const { token, authFetch } = useAuth();

View File

@@ -7,7 +7,7 @@ import type {
OnlineUser, OnlineUser,
Playlist, Playlist,
PublicUser, PublicUser,
} from "../model.ts"; } from "../../model.ts";
export interface VoteEvent { export interface VoteEvent {
dumpId: string; dumpId: string;

View File

@@ -27,7 +27,7 @@ import {
type AvatarOverride, type AvatarOverride,
AvatarOverrideContext, AvatarOverrideContext,
} from "./AvatarOverrideContext.ts"; } from "./AvatarOverrideContext.ts";
import { WS_URL } from "../config/api.ts"; import { WS_URL } from "../../config/api.ts";
import type { import type {
ChatMessage, ChatMessage,
Dump, Dump,
@@ -35,7 +35,7 @@ import type {
Notification, Notification,
OnlineUser, OnlineUser,
OutgoingWSMessage, OutgoingWSMessage,
} from "../model.ts"; } from "../../model.ts";
import { import {
deserializeChatMessage, deserializeChatMessage,
deserializeComment, deserializeComment,
@@ -43,12 +43,12 @@ import {
deserializeNotification, deserializeNotification,
deserializePlaylist, deserializePlaylist,
deserializePublicUser, deserializePublicUser,
} from "../model.ts"; } from "../../model.ts";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { setTitleBadge } from "../hooks/useDocumentTitle.ts"; import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts";
import { playChime } from "../utils/sound.ts"; import { playChime } from "../../utils/shared/sound.ts";
import { mentionsUser } from "../utils/chatText.ts"; import { mentionsUser } from "../../utils/chat/text.ts";
interface WSProviderProps { interface WSProviderProps {
children: ReactNode; children: ReactNode;

View File

@@ -1,13 +1,13 @@
import { useCallback, useContext } from "react"; import { useCallback, useContext } from "react";
import { AuthContext } from "../contexts/AuthContext.ts"; import { AuthContext } from "../../contexts/auth/AuthContext.ts";
import { type AuthResponse } from "../model.ts"; import { type AuthResponse } from "../../model.ts";
import { import {
forgetStoredSession, forgetStoredSession,
isTokenExpired, isTokenExpired,
storeSession, storeSession,
} from "../utils/authStorage.ts"; } from "../../utils/auth/storage.ts";
export const useAuth = () => { export const useAuth = () => {
const { authResponse, setAuthResponse } = useContext(AuthContext); const { authResponse, setAuthResponse } = useContext(AuthContext);

View File

@@ -1,4 +1,4 @@
import { useContext } from "react"; import { useContext } from "react";
import { CategoriesContext } from "../contexts/CategoriesContext.ts"; import { CategoriesContext } from "../../contexts/category/CategoriesContext.ts";
export const useCategories = () => useContext(CategoriesContext); export const useCategories = () => useContext(CategoriesContext);

View File

@@ -1,5 +1,5 @@
import { useContext } from "react"; import { useContext } from "react";
import { ChatContext } from "../contexts/ChatContext.ts"; import { ChatContext } from "../../contexts/chat/ChatContext.ts";
export function useChat() { export function useChat() {
return useContext(ChatContext); return useContext(ChatContext);

View File

@@ -1,8 +1,8 @@
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { Dump } from "../model.ts"; import type { Dump } from "../../model.ts";
import { dumpUrl } from "../utils/urls.ts"; import { dumpUrl } from "../../utils/shared/urls.ts";
import { markDumpVisited } from "../utils/visited.ts"; import { markDumpVisited } from "../../utils/shared/visited.ts";
function isOverLink(e: MouseEvent) { function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null; return (e.target as Element).closest("a") !== null;

View File

@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef } from "react";
import type { Dump } from "../model.ts"; import type { Dump } from "../../model.ts";
import { useWS } from "./useWS.ts"; import { useWS } from "../ws/useWS.ts";
interface DumpListSyncOptions { interface DumpListSyncOptions {
/** Keep private dumps visible (caller is the owner). */ /** Keep private dumps visible (caller is the owner). */

View File

@@ -7,18 +7,18 @@ import {
useState, useState,
} from "react"; } from "react";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; 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 { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts"; import type { Dump, PaginatedData, PublicUser, RawDump } from "../../model.ts";
import { import {
deserializeDump, deserializeDump,
deserializePublicUser, deserializePublicUser,
hydrateDump, hydrateDump,
} from "../model.ts"; } from "../../model.ts";
import { useAuth } from "./useAuth.ts"; import { useAuth } from "../auth/useAuth.ts";
import { useFeedCache } from "./useFeedCache.ts"; import { useFeedCache } from "../feed/useFeedCache.ts";
import { useInfiniteScroll } from "./useInfiniteScroll.ts"; import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts";
import { useScrollSave } from "./useScrollSave.ts"; import { useScrollSave } from "../feed/useScrollSave.ts";
type State = type State =
| { status: "loading" } | { status: "loading" }

View File

@@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { insideCode } from "../utils/editorText.ts"; import { insideCode } from "../../utils/editor/text.ts";
// Trigger: ':' not preceded by a letter, digit or another ':', followed by a // Trigger: ':' not preceded by a letter, digit or another ':', followed by a
// letter then at least one more word char / '+' / '-'. Two characters, not // letter then at least one more word char / '+' / '-'. Two characters, not

View File

@@ -5,8 +5,8 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { insideCode } from "../utils/editorText.ts"; import { insideCode } from "../../utils/editor/text.ts";
export interface UserResult { export interface UserResult {
id: string; id: string;

View File

@@ -3,7 +3,7 @@ import {
DEFAULT_FEED_TAB, DEFAULT_FEED_TAB,
FEED_TABS, FEED_TABS,
type FeedTab, type FeedTab,
} from "../config/feedTabs.ts"; } from "../../config/feedTabs.ts";
const STORAGE_KEY = "default-feed-tab"; const STORAGE_KEY = "default-feed-tab";

View File

@@ -7,8 +7,7 @@ import {
type UseFormReturn, type UseFormReturn,
} from "react-hook-form"; } from "react-hook-form";
import { friendlyFetchError } from "../../utils/apiError.ts"; import { friendlyFetchError } from "../../utils/shared/api.ts";
import { parseAPIResponse } from "../../model.ts";
export interface UseApiFormReturn<T extends FieldValues> export interface UseApiFormReturn<T extends FieldValues>
extends UseFormReturn<T> { extends UseFormReturn<T> {
@@ -16,7 +15,7 @@ export interface UseApiFormReturn<T extends FieldValues>
* Wraps an async submit handler with the shared API-form lifecycle: clears the * Wraps an async submit handler with the shared API-form lifecycle: clears the
* previous root error, runs the handler while RHF keeps `formState.isSubmitting` * previous root error, runs the handler while RHF keeps `formState.isSubmitting`
* true, and on a thrown error surfaces a single root error via * true, and on a thrown error surfaces a single root error via
* `friendlyFetchError`. Pair it with {@link expectOk} so API-level failures and * `friendlyFetchError`. Pair it with `expectOk` so API-level failures and
* network failures both become root errors through one code path. Returns the * network failures both become root errors through one code path. Returns the
* `onSubmit` handler to spread onto `<form onSubmit={...}>`. * `onSubmit` handler to spread onto `<form onSubmit={...}>`.
*/ */
@@ -46,15 +45,3 @@ export function useApiForm<T extends FieldValues>(
return { ...form, submit }; return { ...form, submit };
} }
/**
* Reads a fetch `Response` through the standard API envelope and returns the
* payload, throwing the server's message on `success: false`. A thrown error
* here (or a network `TypeError` from the `fetch` itself) is caught by
* {@link useApiForm}'s `submit` and rendered as the form's root error.
*/
export async function expectOk<T>(res: Response): Promise<T> {
const body = parseAPIResponse<T>(await res.json());
if (!body.success) throw new Error(body.error.message);
return body.data;
}

View File

@@ -1,7 +1,10 @@
import { useCallback, useContext, useState } from "react"; import { useCallback, useContext, useState } from "react";
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts"; import {
import { BANDCAMP_PLAYER } from "../config/playerMode.ts"; PlayerContext,
import { resolveBandcampQueue } from "../utils/bandcamp.ts"; type PlayerItem,
} from "../../contexts/player/PlayerContext.ts";
import { BANDCAMP_PLAYER } from "../../config/playerMode.ts";
import { resolveBandcampQueue } from "../../utils/player/bandcamp.ts";
export interface PlayableRichContent { export interface PlayableRichContent {
type: string; type: string;

View File

@@ -10,7 +10,7 @@ import {
embedListId, embedListId,
fetchPlaylistTracks, fetchPlaylistTracks,
type YouTubeTrack, type YouTubeTrack,
} from "../utils/youtube.ts"; } from "../../utils/player/youtube.ts";
/** /**
* Tracklist and transport for a YouTube playlist embed. * Tracklist and transport for a YouTube playlist embed.

View File

@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef } from "react";
import type { Playlist } from "../model.ts"; import type { Playlist } from "../../model.ts";
import { useWS } from "./useWS.ts"; import { useWS } from "../ws/useWS.ts";
interface PlaylistListSyncOptions { interface PlaylistListSyncOptions {
/** Keep private playlists visible (caller is the owner). */ /** Keep private playlists visible (caller is the owner). */

View File

@@ -1,5 +1,8 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { readSoundsEnabled, writeSoundsEnabled } from "../utils/sound.ts"; import {
readSoundsEnabled,
writeSoundsEnabled,
} from "../../utils/shared/sound.ts";
/** /**
* User preference for notification and chat sounds, persisted to localStorage * User preference for notification and chat sounds, persisted to localStorage

Some files were not shown because too many files have changed in this diff Show More