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:
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
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 {
|
||||
initial: string;
|
||||
|
||||
@@ -3,16 +3,16 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Link } from "react-router";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { ConfirmModal } from "../ui/ConfirmModal.tsx";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import type { ChatMessage } from "../../model.ts";
|
||||
import { ChatEditBox } from "./ChatEditBox.tsx";
|
||||
import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "./message.ts";
|
||||
import { clockTime } from "./time.ts";
|
||||
import { messageTextClass } from "../../utils/chat/message.ts";
|
||||
import { clockTime } from "../../utils/chat/time.ts";
|
||||
|
||||
export interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
|
||||
@@ -9,19 +9,19 @@ import {
|
||||
} from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { Modal } from "../shared/Modal.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import { useWS, useWSChat } from "../../hooks/useWS.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { can } from "../../utils/permissions.ts";
|
||||
import { readDraft, writeDraft } from "../../utils/chatDraft.ts";
|
||||
import { mentionsUser } from "../../utils/chatText.ts";
|
||||
import { useWS, useWSChat } from "../../hooks/ws/useWS.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { can } from "../../utils/auth/permissions.ts";
|
||||
import { readDraft, writeDraft } from "../../utils/chat/draft.ts";
|
||||
import { mentionsUser } from "../../utils/chat/text.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type {
|
||||
ChatReplyTarget,
|
||||
PendingChatMessage,
|
||||
} from "../../contexts/WSContext.ts";
|
||||
} from "../../contexts/ws/WSContext.ts";
|
||||
import {
|
||||
type ChatMessage,
|
||||
deserializeChatMessage,
|
||||
@@ -29,8 +29,13 @@ import {
|
||||
} from "../../model.ts";
|
||||
import { ChatMessageItem } from "./ChatMessageItem.tsx";
|
||||
import { PendingItem } from "./PendingItem.tsx";
|
||||
import { COUNT_FROM, MAX_CHAT_LENGTH, sendable, snippet } from "./message.ts";
|
||||
import { dayLabel, isSameDay } from "./time.ts";
|
||||
import {
|
||||
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.
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import type { PendingChatMessage } from "../../contexts/WSContext.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import type { PendingChatMessage } from "../../contexts/ws/WSContext.ts";
|
||||
import type { PublicUser } from "../../model.ts";
|
||||
import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "./message.ts";
|
||||
import { messageTextClass } from "../../utils/chat/message.ts";
|
||||
|
||||
export interface PendingItemProps {
|
||||
pending: PendingChatMessage;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { snippet } from "./message.ts";
|
||||
import { snippet } from "../../utils/chat/message.ts";
|
||||
|
||||
export interface ReplyRefProps {
|
||||
replyToId: string;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { markdownToPlainText } from "../../utils/chatText.ts";
|
||||
import { isEmojiOnly } from "../../utils/editorText.ts";
|
||||
|
||||
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
||||
export const MAX_CHAT_LENGTH = 2000;
|
||||
// The counter only shows once a message is getting near the limit.
|
||||
export const COUNT_FROM = 1800;
|
||||
|
||||
// What the server holds a message to: its length once trimmed.
|
||||
export function sendable(draft: string): boolean {
|
||||
const body = draft.trim();
|
||||
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
|
||||
}
|
||||
|
||||
// A message of nothing but a few emoji is shown large, as chats do.
|
||||
export function messageTextClass(body: string): string {
|
||||
return isEmojiOnly(body)
|
||||
? "chat-message-text chat-message-text--jumbo"
|
||||
: "chat-message-text";
|
||||
}
|
||||
|
||||
// One line of plain text, for reply previews.
|
||||
export function snippet(body: string): string {
|
||||
return markdownToPlainText(body, t`image`);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Timestamps in the chat: day separators and the grouped-message gutter.
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { i18n } from "../../i18n.ts";
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
|
||||
// days, otherwise a localized full date.
|
||||
export function dayLabel(date: Date): string {
|
||||
const now = new Date();
|
||||
if (isSameDay(date, now)) return t`Today`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (isSameDay(date, yesterday)) return t`Yesterday`;
|
||||
return date.toLocaleDateString(i18n.locale, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Hours and minutes only, for the narrow gutter beside a grouped message — the
|
||||
// day period ("PM") is dropped to fit; its tooltip has the full timestamp.
|
||||
export function clockTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat(i18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date)
|
||||
.filter((part) => part.type !== "dayPeriod")
|
||||
.map((part) => part.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import React from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { Controller, FormProvider } from "react-hook-form";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { VALIDATION } from "../../config/api.ts";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import {
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
} from "../form/index.ts";
|
||||
import { FormError } from "../form/FormError.tsx";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
|
||||
export interface CommentFormProps {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { can } from "../../utils/permissions.ts";
|
||||
import { can } from "../../utils/auth/permissions.ts";
|
||||
import type {
|
||||
Comment,
|
||||
CreateCommentRequest,
|
||||
@@ -12,17 +12,17 @@ import type {
|
||||
User,
|
||||
} 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 type { TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import { LikeButton } from "../social/LikeButton.tsx";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
import { ConfirmModal } from "../ui/ConfirmModal.tsx";
|
||||
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
|
||||
import { useWS } from "../../hooks/useWS.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { expectOk } from "../form/index.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
|
||||
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
||||
import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
|
||||
interface CommentNodeProps {
|
||||
|
||||
@@ -9,9 +9,9 @@ import type {
|
||||
User,
|
||||
} from "../../model.ts";
|
||||
import { deserializeComment } from "../../model.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { expectOk } from "../form/index.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
import { CommentNode } from "./CommentNode.tsx";
|
||||
|
||||
|
||||
@@ -2,22 +2,22 @@ import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../../model.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useCategories } from "../../hooks/useCategories.ts";
|
||||
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useCategories } from "../../hooks/category/useCategories.ts";
|
||||
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
|
||||
import {
|
||||
isDumpVisited,
|
||||
isRecent,
|
||||
markDumpVisited,
|
||||
} from "../../utils/visited.ts";
|
||||
} from "../../utils/shared/visited.ts";
|
||||
import { FilePreview } from "./FilePreview.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 { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
|
||||
interface DumpCardProps {
|
||||
dump: Dump;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import type { Dump } from "../../model.ts";
|
||||
import { formatBytes } from "../../utils/format.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { IconPause, IconPlay } from "../player/icons.tsx";
|
||||
import { formatBytes } from "../../utils/shared/format.ts";
|
||||
import {
|
||||
dumpFileUrl,
|
||||
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 {
|
||||
PlayerContext,
|
||||
type PlayerItem,
|
||||
PlayerTimeContext,
|
||||
} from "../../contexts/PlayerContext.ts";
|
||||
} from "../../contexts/player/PlayerContext.ts";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
@@ -17,7 +21,7 @@ import {
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../../utils/waveform.ts";
|
||||
} from "../../utils/player/waveform.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
|
||||
@@ -2,28 +2,28 @@ import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../../model.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
|
||||
import {
|
||||
isDumpVisited,
|
||||
isRecent,
|
||||
markDumpVisited,
|
||||
} from "../../utils/visited.ts";
|
||||
} from "../../utils/shared/visited.ts";
|
||||
import {
|
||||
hasQuote,
|
||||
hasThumbnail,
|
||||
type JournalShape,
|
||||
} from "../../utils/journalLayout.ts";
|
||||
} from "../../utils/feed/journalLayout.ts";
|
||||
import { VoteButton } from "../social/VoteButton.tsx";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
import { Thumbnail } from "../ui/Thumbnail.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
import {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../../hooks/usePlayRichContent.ts";
|
||||
} from "../../hooks/player/usePlayRichContent.ts";
|
||||
|
||||
export type { JournalShape };
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { RichContent } from "../../model.ts";
|
||||
import { PlayerContext } from "../../contexts/PlayerContext.ts";
|
||||
import { PlayerContext } from "../../contexts/player/PlayerContext.ts";
|
||||
import {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../../hooks/usePlayRichContent.ts";
|
||||
import { Thumbnail } from "../ui/Thumbnail.tsx";
|
||||
} from "../../hooks/player/usePlayRichContent.ts";
|
||||
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
|
||||
interface RichContentCardProps {
|
||||
richContent: RichContent;
|
||||
|
||||
@@ -19,34 +19,32 @@ import {
|
||||
deserializeDumpUrlMatch,
|
||||
deserializePlaylistMembership,
|
||||
} from "../../../model.ts";
|
||||
import { useAuth } from "../../../hooks/useAuth.ts";
|
||||
import { useWS } from "../../../hooks/useWS.ts";
|
||||
import { dumpUrl, normalizeUrl } from "../../../utils/urls.ts";
|
||||
import { titleFromFileName } from "../../../utils/format.ts";
|
||||
import { useAuth } from "../../../hooks/auth/useAuth.ts";
|
||||
import { useWS } from "../../../hooks/ws/useWS.ts";
|
||||
import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts";
|
||||
import { titleFromFileName } from "../../../utils/shared/format.ts";
|
||||
import {
|
||||
clearDumpDraft,
|
||||
type DumpDraft,
|
||||
EMPTY_DUMP_DRAFT,
|
||||
loadDumpDraft,
|
||||
saveDumpDraft,
|
||||
} from "../../../utils/dumpDraft.ts";
|
||||
} from "../../../utils/dump/draft.ts";
|
||||
import { MAX_FILE_SIZE } from "../../../config/upload.ts";
|
||||
import { RichContentCard } from "../RichContentCard.tsx";
|
||||
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 { CategorySelect } from "../../category/CategorySelect.tsx";
|
||||
import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FileField,
|
||||
FormError,
|
||||
FormProvider,
|
||||
RichTextField,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "../../form/index.ts";
|
||||
import { expectOk } from "../../../utils/shared/api.ts";
|
||||
import { FileField } from "../../form/FileField.tsx";
|
||||
import { FormError } from "../../form/FormError.tsx";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { RichTextField } from "../../form/RichTextField.tsx";
|
||||
import { SubmitButton } from "../../form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../../../hooks/form/useApiForm.ts";
|
||||
import { VisibilityToggle } from "../../form/SegmentedField.tsx";
|
||||
import { DuplicateNotice } from "./DuplicateNotice.tsx";
|
||||
import { LocalFilePreview } from "./LocalFilePreview.tsx";
|
||||
import { type Step, StepIndicator } from "./StepIndicator.tsx";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { DumpUrlMatch } from "../../../model.ts";
|
||||
import { dumpUrl } from "../../../utils/urls.ts";
|
||||
import { relativeTime } from "../../../utils/relativeTime.ts";
|
||||
import { dumpUrl } from "../../../utils/shared/urls.ts";
|
||||
import { relativeTime } from "../../../utils/shared/relativeTime.ts";
|
||||
|
||||
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
|
||||
export function DuplicateNotice(
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
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. */
|
||||
function SkinTonePersist() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type CSSProperties, useLayoutEffect, useRef } from "react";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import type { UserResult } from "../../hooks/useMentionAutocomplete.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import type { UserResult } from "../../hooks/editor/useMentionAutocomplete.ts";
|
||||
|
||||
interface MentionDropdownProps {
|
||||
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
|
||||
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/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 { MentionDropdown } from "./MentionDropdown.tsx";
|
||||
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
|
||||
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useMentionAutocomplete } from "../../hooks/editor/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../../hooks/editor/useEmojiTrigger.ts";
|
||||
import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
export interface TextEditorHandle {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// The emoji picker's skin tone, remembered across pickers and reloads.
|
||||
|
||||
import type { SkinTone } from "frimousse";
|
||||
|
||||
export const SKIN_TONE_KEY = "emoji-skin-tone";
|
||||
|
||||
export function readSkinTone(): SkinTone {
|
||||
try {
|
||||
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
96
src/components/feed/DumpFeed.tsx
Normal file
96
src/components/feed/DumpFeed.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { type Ref, useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DumpCard } from "../dump/DumpCard.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
import type { Dump, User } from "../../model.ts";
|
||||
import { hotScore } from "../../utils/feed/hotScore.ts";
|
||||
|
||||
// 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,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
sentinelRef,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
user,
|
||||
castVote,
|
||||
removeVote,
|
||||
}: MainFeedProps & { order: "hot" | "new" },
|
||||
) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...dumps].sort((a, b) =>
|
||||
order === "hot"
|
||||
? hotScore(b) - hotScore(a)
|
||||
: b.createdAt.getTime() - a.createdAt.getTime()
|
||||
),
|
||||
[dumps, order],
|
||||
);
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
src/components/feed/FeedTabBar.tsx
Normal file
52
src/components/feed/FeedTabBar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import {
|
||||
DEFAULT_FEED_TAB,
|
||||
FEED_TABS,
|
||||
type FeedTab,
|
||||
} from "../../config/feedTabs.ts";
|
||||
import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { CategorySwitcher } from "../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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
385
src/components/feed/FollowedFeed.tsx
Normal file
385
src/components/feed/FollowedFeed.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DumpCard } from "../dump/DumpCard.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts";
|
||||
import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
|
||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { useScrollSave } from "../../hooks/feed/useScrollSave.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
type Dump,
|
||||
hydrateDump,
|
||||
type PaginatedData,
|
||||
type RawDump,
|
||||
type User,
|
||||
} from "../../model.ts";
|
||||
import { friendlyFetchError } from "../../utils/shared/api.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>
|
||||
);
|
||||
}
|
||||
85
src/components/feed/JournalFeed.tsx
Normal file
85
src/components/feed/JournalFeed.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
import { JournalCard } from "../dump/JournalCard.tsx";
|
||||
import { composeJournal } from "../../utils/feed/journalLayout.ts";
|
||||
import type { MainFeedProps } from "./DumpFeed.tsx";
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
src/components/feed/PresenceRow.tsx
Normal file
74
src/components/feed/PresenceRow.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { useWSPresence } from "../../hooks/ws/useWS.ts";
|
||||
import { useRoomBeside } from "../../hooks/shared/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,7 +1,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/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 {
|
||||
if (mime.startsWith("image/")) return "🖼";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
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
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { charCountClass } from "../../utils/charCount.ts";
|
||||
import { charCountClass } from "../../utils/shared/charCount.ts";
|
||||
|
||||
interface TextFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
|
||||
@@ -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";
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type FieldValues,
|
||||
type SubmitHandler,
|
||||
useForm,
|
||||
type UseFormProps,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { friendlyFetchError } from "../../utils/apiError.ts";
|
||||
import { parseAPIResponse } from "../../model.ts";
|
||||
|
||||
export interface UseApiFormReturn<T extends FieldValues>
|
||||
extends UseFormReturn<T> {
|
||||
/**
|
||||
* Wraps an async submit handler with the shared API-form lifecycle: clears the
|
||||
* previous root error, runs the handler while RHF keeps `formState.isSubmitting`
|
||||
* true, and on a thrown error surfaces a single root error via
|
||||
* `friendlyFetchError`. Pair it with {@link expectOk} so API-level failures and
|
||||
* network failures both become root errors through one code path. Returns the
|
||||
* `onSubmit` handler to spread onto `<form onSubmit={...}>`.
|
||||
*/
|
||||
submit: (
|
||||
handler: SubmitHandler<T>,
|
||||
) => (e?: React.BaseSyntheticEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useApiForm<T extends FieldValues>(
|
||||
options?: UseFormProps<T>,
|
||||
): UseApiFormReturn<T> {
|
||||
const form = useForm<T>(options);
|
||||
const { handleSubmit, setError, clearErrors } = form;
|
||||
|
||||
const submit = useCallback(
|
||||
(handler: SubmitHandler<T>) =>
|
||||
handleSubmit(async (values, event) => {
|
||||
clearErrors("root");
|
||||
try {
|
||||
await handler(values, event);
|
||||
} catch (err) {
|
||||
setError("root", { message: friendlyFetchError(err) });
|
||||
}
|
||||
}),
|
||||
[handleSubmit, setError, clearErrors],
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useWS } from "../../hooks/useWS.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
import { NotificationBell } from "./NotificationBell.tsx";
|
||||
import { ChatButton } from "./ChatButton.tsx";
|
||||
import { UserMenu } from "./UserMenu.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(() =>
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useWSChat } from "../../hooks/useWS.ts";
|
||||
import { useChat } from "../../hooks/useChat.ts";
|
||||
import { useWSChat } from "../../hooks/ws/useWS.ts";
|
||||
import { useChat } from "../../hooks/chat/useChat.ts";
|
||||
|
||||
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
|
||||
export function ChatButton() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useWSChat } from "../../hooks/useWS.ts";
|
||||
import { useChat } from "../../hooks/useChat.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useWSChat } from "../../hooks/ws/useWS.ts";
|
||||
import { useChat } from "../../hooks/chat/useChat.ts";
|
||||
|
||||
/**
|
||||
* Floating chat button shown once the header has scrolled out of view, mirroring
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useWS } from "../../hooks/useWS.ts";
|
||||
import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { unreadNotificationCount, lastNotification } = useWS();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { PageShell } from "./PageShell.tsx";
|
||||
import { ErrorCard } from "../ui/ErrorCard.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
|
||||
export function PageError(
|
||||
{ title, message, actions }: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/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";
|
||||
|
||||
export function UserMenu({ user }: { user: User }) {
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
PlayerContext,
|
||||
type PlayerItem,
|
||||
playerItemKey,
|
||||
} from "../../contexts/PlayerContext.ts";
|
||||
} from "../../contexts/player/PlayerContext.ts";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import { fmt } from "../../utils/duration.ts";
|
||||
import { Thumbnail } from "../ui/Thumbnail.tsx";
|
||||
import { useYouTubePlaylist } from "../../hooks/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../../utils/youtube.ts";
|
||||
import { fmt } from "../../utils/player/duration.ts";
|
||||
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
|
||||
|
||||
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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 { IconFullscreen, IconPause, IconPlay, IconVolume } from "./icons.tsx";
|
||||
import {
|
||||
IconFullscreen,
|
||||
IconPause,
|
||||
IconPlay,
|
||||
IconVolume,
|
||||
} from "./PlayerIcons.tsx";
|
||||
import { SeekBar } from "./SeekBar.tsx";
|
||||
import { Waveform } from "./Waveform.tsx";
|
||||
import { rememberVolume, storedVolume } from "./volume.ts";
|
||||
import { rememberVolume, storedVolume } from "../../utils/player/volume.ts";
|
||||
|
||||
const HIDE_DELAY = 2500;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../../utils/waveform.ts";
|
||||
} from "../../utils/player/waveform.ts";
|
||||
|
||||
export function Waveform(
|
||||
{ src, current, duration, onSeek }: {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
|
||||
// which would otherwise snap the volume back to 100% on every track. Hold it
|
||||
// outside the component and mirror it to localStorage so it also survives a
|
||||
// reload.
|
||||
|
||||
const VOLUME_KEY = "player-volume";
|
||||
|
||||
let lastVolume = 1;
|
||||
let lastMuted = false;
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
|
||||
if (stored && typeof stored.volume === "number") {
|
||||
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
|
||||
lastMuted = stored.muted === true;
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unavailable storage — the defaults above stand.
|
||||
}
|
||||
|
||||
export function rememberVolume(volume: number, muted: boolean) {
|
||||
lastVolume = volume;
|
||||
lastMuted = muted;
|
||||
try {
|
||||
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
|
||||
} catch {
|
||||
// Private mode or blocked storage — volume just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The volume and mute state the last player left behind. */
|
||||
export function storedVolume(): { volume: number; muted: boolean } {
|
||||
return { volume: lastVolume, muted: lastMuted };
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
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 { deserializePlaylistMembership } from "../../model.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import { Modal } from "../shared/Modal.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { Playlist } from "../../model.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import { Modal } from "../shared/Modal.tsx";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
interface NewPlaylistButtonProps {
|
||||
|
||||
@@ -2,14 +2,14 @@ import { Link, useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Playlist } from "../../model.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../../utils/urls.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
|
||||
import {
|
||||
isPlaylistVisited,
|
||||
isRecent,
|
||||
markPlaylistVisited,
|
||||
} from "../../utils/visited.ts";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
} from "../../utils/shared/visited.ts";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist;
|
||||
|
||||
@@ -7,18 +7,16 @@ import type {
|
||||
RawPlaylist,
|
||||
} from "../../model.ts";
|
||||
import { deserializePlaylist } from "../../model.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
FormError,
|
||||
FormProvider,
|
||||
RichTextField,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "../form/index.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { FormActions } from "../form/FormActions.tsx";
|
||||
import { FormError } from "../form/FormError.tsx";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { RichTextField } from "../form/RichTextField.tsx";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
import { TextField } from "../form/TextField.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
import { VisibilityToggle } from "../form/SegmentedField.tsx";
|
||||
|
||||
interface PlaylistCreateFormProps {
|
||||
/** If provided, the new playlist will have this dump added to it. */
|
||||
|
||||
@@ -2,17 +2,15 @@ import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
} from "../form/index.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { Modal } from "../shared/Modal.tsx";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { FormActions } from "../form/FormActions.tsx";
|
||||
import { FormError } from "../form/FormError.tsx";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
import { TextField } from "../form/TextField.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
onClose: () => void;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { PublicUser } from "../../model.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
|
||||
interface ProfileSubpageHeaderProps {
|
||||
username: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
||||
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
|
||||
|
||||
interface AvatarProps {
|
||||
userId: string;
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useId } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useScrollLock } from "../../hooks/useScrollLock.ts";
|
||||
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
message: string;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from "react";
|
||||
import { charCountClass } from "../../utils/charCount.ts";
|
||||
import { charCountClass } from "../../utils/shared/charCount.ts";
|
||||
|
||||
interface CountedInputProps
|
||||
extends
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useScrollLock } from "../../hooks/useScrollLock.ts";
|
||||
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
interface ThumbnailProps {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { proxyImageUrl } from "../../utils/proxyImage.ts";
|
||||
import { initialsFor, tintFor } from "../../utils/thumbnailTint.ts";
|
||||
import { proxyImageUrl } from "../../utils/shared/proxyImage.ts";
|
||||
import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts";
|
||||
|
||||
interface ThumbnailPlaceholderProps {
|
||||
/** The dumped URL — seeds the fallback hue and the initial. */
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useFollows } from "../../hooks/useFollows.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useFollows } from "../../hooks/social/useFollows.ts";
|
||||
|
||||
interface FollowUserButtonProps {
|
||||
targetUserId: string;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createPortal } from "react-dom";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import {
|
||||
deserializePublicUser,
|
||||
|
||||
Reference in New Issue
Block a user